diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index 90e215019b..fb1dc93159 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/.agents/notes/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. 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 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/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md b/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md index eacd409a57..b2f4ea66b4 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md +++ b/.agents/notes/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/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index 74ab8b6bf3..00352b01c4 100644 --- a/.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/.agents/notes/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 `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/.agents/notes/implemented/architecture/2026-06-18-session-surface.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md index 1916dc1974..f1297b1f05 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md +++ b/.agents/notes/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/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md index 425c1618eb..9d72611d63 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md +++ b/.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -12,11 +12,11 @@ A valid absolute cwd can itself have two apparent parents: when it contains `sym ## Decision -Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. Resolve that cwd to its native filesystem identity before any lexical join, and reuse the resolved sandbox-policy root for mutations and sandboxed bash calls so one call has one workspace identity. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent. +Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. When the cwd contains a parent segment, resolve it to its native filesystem identity before any lexical join; ordinary cwd spellings stay stable for display. Reuse the resolved sandbox-policy root for mutations and sandboxed bash calls so one call has one workspace identity. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent. - `FileSystem.resolve` accepts `resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. `opts.signal` cancels resolution when the backend performs I/O. The options object keeps both caller-owned resolution controls together without positional growth. - `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace). -- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`), canonicalize it with native realpath semantics, and pass it to `resolve`. A sandboxed mutation reuses the complete policy's `workspaceRoot`; a non-agent / headerless caller yields `undefined`, so the backend applies its default. +- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. The helper uses native realpath semantics when a parent segment could cross a symlink while retaining ordinary spellings; a sandboxed mutation reuses the complete policy's `workspaceRoot`; a non-agent / headerless caller yields `undefined`, so the backend applies its default. ## Alternatives considered 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 162a83ad0c..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.** 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.** 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-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 68b9d2d55b..b25f335819 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: d470cceaff68229b3872d0ade93d5fabc2e10c3f -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: b7753fb226638b16b0f244b681cd2b9bcc9f25c2 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: b934f7fd7087006be4f7eb3659e44e78b8ede367 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 3b5b60a95bef0695a446cdd3d45d299550f449f6 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 d470cceaff..b934f7fd70 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. Below pressure it returns without pruning. Once pressure qualifies, optional `ctx.toolResultPrune` rewrites oversized current results and compact-basic remeasures through the same meter; safe pressure skips the model call, while remaining pressure selects and summarizes from the pruned surface. 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. Below pressure it returns without pruning. Once pressure qualifies, optional `ctx.toolResultPrune` rewrites oversized current results and compact-basic remeasures through the same meter; safe pressure skips the model call, while remaining pressure selects and summarizes from the pruned surface. 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 prunes first, then chooses the maximal tool-balanced head range while leaving the newest indivisible unit and attempts one shrinking summary compaction under the same signal when a range exists. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` whenever pruning or summarization increases it. This remains true when pruning lands before later summary work throws; cancellation still wins. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`. +For canonical overflow, compact-basic requires no capacity metadata and bypasses scalar pressure and the normal retained-token budget. It prunes first, then chooses the maximal tool-balanced head range while leaving the newest indivisible unit and attempts one shrinking summary compaction under the same signal when a range exists. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` whenever pruning or summarization increases it. This remains true when pruning lands before later summary work throws; cancellation still wins. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`. `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 before any replacement all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. A recovery throw after generation advances authorizes retry from durable progress; 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 b7753fb226..3b5b60a95b 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()` 结果。低于压力时直接返回,不执行剪枝。压力达到条件后,可选的 `ctx.toolResultPrune` 会改写当前表层中过大的工具结果,compact-basic 再通过同一个 meter 重新计量;若压力恢复安全则跳过模型调用,否则从已剪枝表层选择范围并生成摘要。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史 `floor(contextWindow × 0.16)`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`。 +对于 `pressure`,compact-basic 先解析持久提供方/模型目标的适配器所属容量与精确目标策略,再把得到的阈值与保留尾部预算应用到一次统一的 `ctx.tokenMeter.measure()` 结果。低于压力时直接返回,不执行剪枝。压力达到条件后,可选的 `ctx.toolResultPrune` 会改写当前表层中过大的工具结果,compact-basic 再通过同一个 meter 重新计量;若压力恢复安全则跳过模型调用,否则从已剪枝表层选择范围并生成摘要。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史比例 `0.16`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`;可选 `modelPolicies` 项可以按精确提供方/模型组合覆盖这些值。 -对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ action: 'retry' }`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 +对于规范化溢出,compact-basic 不要求容量元数据,并绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ action: 'retry' }`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 `maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及在任何替换之前恢复抛错,都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。generation 增加后的恢复抛错会基于持久进展授权重试;即使恢复工作并发完成,取消或销毁仍具有最终优先级。 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 2ddbe0a351..eb7b1d873d 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/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 `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/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml new file mode 100644 index 0000000000..70b98f0cf1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.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-15-lsp-capability-seam.md: 7265b04ac9b2f83764bdd13f07b2d3404c4c1708 +2026-07-15-lsp-capability-seam.zh.md: 10e8956005045d0934dd9dada5718b85a34cda3f diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md new file mode 100644 index 0000000000..7265b04ac9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -0,0 +1,198 @@ +# Agent Note: LSP capability seam and model-facing query tool + +Status: implemented + +English | [中文](2026-07-15-lsp-capability-seam.zh.md) + +## Problem + +The harness has text search and file reads, but neither identifies a program symbol. A textual match cannot reliably distinguish two same-named functions, follow an import alias, connect an interface to its implementations, or report an inferred type. Before changing code, an agent therefore lacks the semantic navigation that a human gets from an editor's language server. + +LSP support has three owners: the model needs a stable query schema, the harness needs provider selection and normalized results, and the local implementation needs process, JSON-RPC, workspace, synchronization, and filesystem behavior. Combining them would bind the model contract to local subprocesses and obstruct remote or sandbox-native providers. + +Many language servers behave best when the queried document is opened with current text. A compatible agent client must bound that state, define whether its source read is a model observation, and keep the document snapshot in the same filesystem namespace as the server's workspace index. + +## Decision + +Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation: + +1. `@deepseek-ai/dsh-lsp` at `packages/lsp/lsp` owns `ctx.lsp`, provider registration and selection, normalized requests/results, execution control, and structured LSP errors. +2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. One plugin instance accepts a named server table and registers one isolated provider for each command and extension-to-language-id mapping. +3. `@deepseek-ai/dsh-tool-lsp` at `packages/lsp/tool-lsp` owns the model-facing `lsp` schema, prompt guidance, argument validation, result limits and formatting, and ACP presentation. + +`dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays. + +The model and seam expose exactly `goToDefinition`, `findReferences`, `goToImplementation`, and `hover`; no arbitrary JSON-RPC method escapes through `ctx.lsp`. These operation literals match Claude Code's familiar camelCase names while the tool name and `file_path` field remain harness-owned. + +The prompt positions LSP as a precision aid: `Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.` + +## Package and ownership boundaries + +`dsh-lsp` registers providers by branded id and extension-to-language-id mapping. `registerProvider()` atomically reserves the id and every normalized extension: invalid input or any conflict publishes nothing, and its disposer releases all reservations. Provider plugins register through `ctx.effect()`. Selection is per query and order-independent; no match returns a structured unavailable error. The first version has no glob, language-id, or explicit route selector and no statically declared operation capabilities. + +The seam exposes one `query(request, signal?)` operation because no fields need implementation defaulting: `workspaceRoot` is required, `languageId` comes from the registration, and consumers own timeouts and result limits. `query()` selects and derives without hidden `??` fallbacks, leaving no executable spec to resolve. `dsh-tool-lsp` validates model arguments and passes only `exec.signal` as a bare `AbortSignal`, matching web and keeping `dsh-lsp` independent of `dsh-tools`. Removal before selection fails as unavailable; later disposal follows the selected provider's cancellation lifecycle without rerouting. + +The intended contract shape is: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' +type LspProviderId = Branded<'LspProviderId'> + +interface LspPosition { + readonly line: number + readonly character: number +} + +interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} + +interface LspQueryRequest { + readonly operation: LspOperation + readonly filePath: string + readonly position: LspPosition + readonly workspaceRoot: string +} + +interface LspProviderQuery extends LspQueryRequest { + readonly languageId: string +} + +type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string } + | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } + +interface LspProvider { + readonly id: LspProviderId + readonly extensionToLanguage: Readonly> + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} + +interface LspService { + registerProvider(provider: LspProvider): () => void + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} +``` + +Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `findReferences` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch. + +`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. The server-table key is its provider id. The plugin resolves every server-local setting before registration, rolls back earlier registrations if a later mapping is invalid or conflicts, and retains an independent process pool per provider. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. + +## Model-facing contract + +The single `lsp` tool accepts: + +```ts +interface LspToolInput { + readonly operation: 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' + readonly file_path: string + readonly line: number + readonly character: number +} +``` + +`line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based `LspPosition` and converts rendered locations back. `findReferences` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable remain outside model input. + +The tool requires `workspaceRoot` from session `header.cwd`, with no fallback; absence fails as `LSP_WORKSPACE_REQUIRED` before querying or startup. The local provider resolves relative paths against that root and accepts absolute paths directly; both forms are canonicalized and rejected before startup when the target is outside the canonical workspace. + +Locations render as stable, file-grouped `path:line:character` entries. A `file:` URI accepted by Node `fileURLToPath()` becomes a relative path inside the workspace or an absolute path outside it; other URIs remain verbatim. `maxLocations` defaults to `100` and reports omitted items; `maxResultChars` defaults to `16_000` and bounds every complete rendered result, including its truncation metadata. Empty locations and `null` hover are successful no-result responses; missing or malformed server payloads fail with structured `LSP_MALFORMED_RESPONSE` errors. + +ACP uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }` with an args-derived operation/cursor `title`. Because `FileLocation` has no character, follow-along focuses the input line while the title preserves the cursor; presentation remains pure. + +## Timeout ownership + +`dsh-tool-lsp` attaches one configurable `timeoutMs` budget, default `60_000`, to the tool definition. `dsh-timeout-policy` enforces it and supplies `exec.signal`, which reaches `ctx.lsp.query`; the budget covers the complete queued open/query/close lifecycle and is not model-configurable. + +The seam and provider add no startup or request deadline. Non-tool callers therefore receive no hidden timeout and must supply an `AbortSignal`, using `deadline()` when they need a budget. + +Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) for both request-cancel grace and SIGTERM-to-SIGKILL escalation; the same bounds govern failed-instance cleanup. Timer values above Node's `2_147_483_647` ms scheduling range fail at load. The provider uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work. + +## Workspace, filesystem, and document synchronization + +`dsh-lsp-local` canonicalizes and reads through Node APIs in the subprocess's host namespace. It rejects missing, non-regular, non-UTF-8, oversized, or canonical out-of-workspace sources and keeps one `O_NOFOLLOW | O_NONBLOCK` handle through validation and reading, so a FIFO with no writer cannot block before the regular-file check. It observes caller cancellation around each filesystem operation. It does not consume `ctx.fs` or emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy. + +The `read` tool is unsuitable source because its output is windowed, numbered, transcript-visible, and observed. Reading in `tool-lsp` would also assign provider-specific synchronization to the consumer and preclude non-local providers. + +The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`. + +1. Canonicalize and validate the host path, then read the current source with Node filesystem APIs. +2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. Its write remains abortable; failure or cancellation invalidates the instance and awaits bounded process termination before the pool can reuse it. +3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request. +4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination. + +Documents close after each call, so the first version needs no `didChange`, `didSave`, content cache, mutation listener, or document LRU. One abortable per-workspace provider queue serializes source-read/open/query/close lifecycles, so a waiting query reads current bytes only when its turn starts; the instance also keeps protocol lifecycles serialized. Distinct workspaces may run in parallel. The server's workspace index remains responsible for closed files reached from the source. + +The canonical workspace `realpath` must be a directory and supplies process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity; symlink aliases therefore share an instance. Result locations may be external, but an external path cannot become a query source. Remote, virtual, or independently sandboxed filesystems require another provider. + +## Local server lifecycle and protocol behavior + +`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace realpath)`. At load it resolves the executable after credential scrubbing and environment overrides, failing before registration if unavailable; server process launch stays lazy (first query spawns it) and uses no shell. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter. + +Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits. + +Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `targetSelectionRange`. Positions must be nonnegative integers. Hover normalization accepts only valid `MarkupContent` and `MarkedString` shapes, preserves string values, renders language-tagged values as fenced code, and joins arrays with one blank line. The model-facing tool applies `maxResultChars` after rendering. + +Abort reaches every query phase and sends `$/cancelRequest` once an id exists. An unresponsive server is terminated and awaited without collateral active work because the instance is serialized. Disposal rejects and cancels work, attempts graceful shutdown, escalates through bounded termination, and awaits quiescence. + +## Deliberately deferred surface + +Symbols are deferred because they need different schemas and overlap read/search; a future workspace-symbol tool must accept a search query. Call hierarchy is deferred because support is uneven, and `prepareCallHierarchy` remains an internal prerequisite rather than a model operation. + +Diagnostics need separate freshness, accumulation, and transcript rules. Mutations such as rename, code actions, and formatting require separate tools with preview, permission, and write-policy integration. + +The local provider trusts its configured server and claims no sandbox confinement. Supporting untrusted binaries requires a later process/filesystem contract for workspace reads plus private cache and temporary writes; restricted, remote, or virtual workspaces require another provider. + +## Alternatives considered + +**Copy Claude Code's unified schema.** Its cursor operations validate the core use case, but symbols and call hierarchy need different arguments. Copying all nine operations would freeze speculative surface, so the proposal aligns only on the four semantic queries. + +**Let providers register tools.** Loaded servers would then control model schema and prompts, preventing one stable contract across local and remote providers. + +**Expose arbitrary LSP methods.** A JSON-RPC escape hatch would leak protocol payloads and admit unreviewed mutation or command execution; the operation union stays closed. + +**Expose `resolve(request)` / `query(spec)`.** With no defaulted fields, resolution would only expose provider selection, and a public spec could outlive provider disposal or replacement. One operation keeps selection and invocation atomic to the registration lifetime. + +**Wrap the signal in a per-seam execution-context object.** Web passes a bare `AbortSignal`; wrapping this single field would add unexplained asymmetry. `query()` gains a context object only when another field requires it. + +**Read through `ctx.fs` or the `read` tool.** This could mix the document with a server index from another filesystem namespace; tool output is also windowed, numbered, and observed. The host-local provider reads unobserved full text beside its subprocess. + +**Keep documents open.** Mirroring edits requires version ownership, all-path `didChange`, HMR recovery, eviction, and stale-state rules. Transient opens avoid that MVP state machine. + +**Configure phase timeouts.** Nested timers create competing classifications and fresh budgets. One caller-owned deadline covers query work; only out-of-call teardown keeps local bounds. + +**Query without `didOpen`.** Although permitted, support is inconsistent and may use stale server state. Transient open supplies an explicit current snapshot. + +**Add routes or select the first match.** Registration order and HMR timing are not product semantics, while a route table duplicates unique extension ownership. Overlaps therefore fail registration. + +**Run concurrent queries in one instance.** If cancellation fails, terminating the shared process would kill unrelated work. Per-instance serialization limits that blast radius; instances remain parallel. + +**Ship presets or PATH discovery.** A catalog would make the generic host own language policy, while discovery cannot infer arguments, language ids, or initialization. Deployments configure providers explicitly; composition plugins may package presets. + +## Testing + +- Package tests pin the three-package dependency direction, runtime injections, and `ctx.lsp`-only communication. +- Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation. +- Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors. +- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `findReferences` mapping to `references.includeDeclaration`. +- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, blocked and failed open writes, balanced transient open/close, close-write failure, and malformed-response rejection. +- Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown. +- Lifecycle tests pin startup single-flight, complete-lifecycle serialization with fresh queued source reads, cross-workspace parallelism, abortable queues, crash replacement without replay, failed-stdin teardown, and quiescent disposal. +- Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event. +- A keyless pinned TypeScript real-server e2e exercises all four operations; runnable configuration uses the same explicit provider mapping. +- Snapshots cover model-visible schema, prompt, results, omissions, and ACP rendering; a built-artifact smoke test covers framing and cleanup. +- Package and architecture docs cover configuration, security boundaries, and search/read guidance; the new `packages/lsp/` group is added to the AGENTS.md repository-layout block, the packages/README.md group table, and architecture.md in the same change. + +## Consequences + +Language servers vary in method support, capability interpretation, and indexing readiness; LSP has no universal “index complete” signal. Servers without compatible transient-open synchronization are unsupported even if closed-document queries work. Supported servers may still return empty or partial results, so the tool promises no cross-server completeness. The pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. + +Transient opens repeat parsing and notifications. Per-instance serialization increases latency under parallel agents, and long-lived workspace processes consume memory until disposal. + +Extension ownership is exclusive within one runtime. Two providers cannot both claim `.ts`, even with different language ids; this is a conscious MVP limit. The intended extension is a deployment-configured selector above registrations that can relax exclusive reservations without adding provider choice to model input or changing `LspProvider.query`. + +UTF-16 cursor columns are exact for the protocol but difficult for a model to count around non-BMP characters. Invalid or off-symbol positions may produce empty results, so error text and prompt examples must explain the coordinate convention without encouraging broad LSP use. + +Direct Node access aligns the query snapshot with the server index but bypasses `ctx.fs` and its policy. Canonical containment rejects source files outside the workspace; a trusted server may still read the workspace and use caches. The first implementation therefore requires trusted host-local deployment and provides no sandbox guarantee. diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md new file mode 100644 index 0000000000..10e8956005 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -0,0 +1,198 @@ +# Agent Note: LSP 能力服务边界与面向模型的查询工具 + +Status: implemented + +[English](2026-07-15-lsp-capability-seam.md) | 中文 + +## 问题 + +harness 已具备文本搜索与文件读取能力,但二者都无法识别程序符号。文本匹配无法可靠地区分同名函数、跟踪导入别名、关联接口与具体实现,也无法报告推断类型。因此,agent(智能体)在修改代码前缺少人类通过编辑器语言服务器获得的语义导航能力。 + +语言服务器协议(Language Server Protocol,LSP)支持分属三个职责方:模型需要稳定的查询 schema,harness 需要提供方选择与规范化结果,本地实现则负责进程、JSON-RPC、工作区、同步与文件系统行为。将三者合并会使模型契约绑定本地子进程,并阻碍远程或沙箱原生提供方。 + +许多语言服务器在查询文档已按当前文本打开时表现最佳。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。 + +## 决策 + +将 LSP 建成由三个包(package)组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: + +1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。 +2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。一个插件实例接收具名服务器表,并为每组命令及扩展名到语言 id 的映射注册一个隔离的提供方。 +3. `packages/lsp/tool-lsp` 下的 `@deepseek-ai/dsh-tool-lsp` 负责面向模型的 `lsp` schema、提示词指导、参数校验、结果限制与格式化,以及 ACP(Agent Client Protocol)展示。 + +`dsh-lsp-local` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。 + +模型与服务边界仅公开 `goToDefinition`、`findReferences`、`goToImplementation` 和 `hover`;`ctx.lsp` 不提供任意 JSON-RPC 方法。这些操作字面量与 Claude Code 熟悉的 camelCase 命名一致,而工具名与 `file_path` 字段仍由 harness 自行定义。 + +提示词将 LSP 定位为精确查询手段:`Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.` + +## 包与职责边界 + +`dsh-lsp` 按带品牌类型的 id 和扩展名到语言 id 的映射注册提供方。`registerProvider()` 以原子方式占用 id 与所有规范化扩展名:输入无效或存在冲突时不发布任何状态,清理函数释放全部占用。提供方插件通过 `ctx.effect()` 注册。系统按查询且不受顺序影响地选择提供方;没有匹配项时返回结构化不可用错误。第一版不提供 glob、language-id 或显式路由选择器,也不静态声明操作能力。 + +服务边界只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 校验模型参数,并只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的释放遵循已选提供方的取消生命周期,不改路由。 + +预期契约如下: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' +type LspProviderId = Branded<'LspProviderId'> + +interface LspPosition { + readonly line: number + readonly character: number +} + +interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} + +interface LspQueryRequest { + readonly operation: LspOperation + readonly filePath: string + readonly position: LspPosition + readonly workspaceRoot: string +} + +interface LspProviderQuery extends LspQueryRequest { + readonly languageId: string +} + +type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string } + | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } + +interface LspProvider { + readonly id: LspProviderId + readonly extensionToLanguage: Readonly> + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} + +interface LspService { + registerProvider(provider: LspProvider): () => void + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} +``` + +映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`findReferences` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 + +`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 + +## 面向模型的契约 + +单一 `lsp` 工具接受以下参数: + +```ts +interface LspToolInput { + readonly operation: 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' + readonly file_path: string + readonly line: number + readonly character: number +} +``` + +`line` 和 `character` 是从一开始计数的正数 UTF-16 光标坐标;工具将其转换为服务边界中从零开始的 `LspPosition`,并将渲染位置转回。`findReferences` 包含声明,避免影响分析漏掉定义位置。提供方、语言 id、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。 + +工具必须从会话 `header.cwd` 取得 `workspaceRoot`,没有后备值;缺失时在查询或启动前以 `LSP_WORKSPACE_REQUIRED` 失败。本地提供方基于根目录解析相对路径并直接接受绝对路径;两种路径都会进行规范化,如果目标位于规范工作区外,则在启动前拒绝。 + +位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,并报告省略的条目;`maxResultChars` 默认值为 `16_000`,并限制每个完整渲染结果,其中包括截断元数据。空位置与 `null` hover 是成功的无结果响应;服务器载荷缺失或格式错误时,以结构化 `LSP_MALFORMED_RESPONSE` 错误失败。 + +ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示保持纯函数。 + +## 超时归属 + +`dsh-tool-lsp` 将一个可配置的 `timeoutMs` 预算附加到工具定义,默认值为 `60_000`。`dsh-timeout-policy` 执行预算并提供传入 `ctx.lsp.query` 的 `exec.signal`;该预算覆盖排队、打开、查询和关闭的完整生命周期,模型不可配置。 + +服务边界和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`。 + +提供方释放发生在工具执行之外,因此 `dsh-lsp-local` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`),同时用于限制请求取消宽限期和从 SIGTERM 升级到 SIGKILL 的宽限期;失败实例的清理也使用相同边界。定时器值超过 Node `2_147_483_647` ms 的调度范围时,插件加载失败。提供方使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。 + +## 工作区、文件系统与文档同步 + +`dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一个 `O_NOFOLLOW | O_NONBLOCK` 句柄,因此没有写入方的 FIFO 不会在普通文件校验前造成阻塞。它在每项文件系统操作前后检查调用方是否取消。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。 + +`read` 工具的输出带窗口与行号,进入 transcript(文本记录)且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。 + +本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。 + +1. 规范化并校验主机路径,再使用 Node 文件系统 API 读取当前源文件。 +2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。该写入仍可取消;写入失败或遭取消会使实例失效,并等待有界进程终止完成,池才能复用它。 +3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。 +4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。 + +每次调用后都关闭文档,因此第一版不需要 `didChange`、`didSave`、内容缓存、变更监听器或文档 LRU。每个工作区的提供方队列可取消,并串行执行源文件读取、打开、查询和关闭的完整生命周期,因此等待中的查询只在轮到它时才读取当前字节;实例也会串行执行协议生命周期。不同工作区可以并行。服务器工作区索引仍负责从源文件跳转到的已关闭文件。 + +规范工作区 `realpath` 必须是目录,并用于进程 cwd、`rootUri`、唯一的 `workspaceFolders` 条目和进程池 identity;符号链接别名因此共享实例。结果位置可以在工作区外,但外部路径不能成为查询源。远程、虚拟或独立沙箱化文件系统需要另一种提供方。 + +## 本地服务器生命周期与协议行为 + +`dsh-lsp-local` 按 `(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败。服务器进程的启动保持懒执行(首次查询时才拉起),且不经过 shell。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。 + +初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。 + +导航结果直接映射 `Location`,并将 `LocationLink` 的 `targetUri` 与 `targetSelectionRange` 映射为统一位置。位置必须是非负整数。`hover` 归一化只接受有效的 `MarkupContent` 和 `MarkedString` 结构,保留字符串值,把带语言标签的值渲染为围栏代码块,并以一个空行连接数组。面向模型的工具在渲染后应用 `maxResultChars`。 + +取消信号传递到查询的所有阶段,请求 id 创建后还会发送 `$/cancelRequest`。无响应的服务器会被终止并等待关闭;实例串行化保证没有其他正在执行的工作被连带中断。资源释放会拒绝并取消工作、尝试优雅关闭、通过有界终止流程升级处理,并等待完全停稳。 + +## 明确延后的接口 + +符号操作因需要不同 schema 且与读取或搜索重叠而延后;未来的工作区符号工具必须接收搜索词。调用层级因支持度不一而延后,`prepareCallHierarchy` 仍是内部准备步骤,不是模型操作。 + +诊断需要独立的新鲜度、累积与 transcript 规则。重命名、代码操作和格式化等变更能力需要单独工具,并集成预览、权限和写入策略。 + +本地提供方信任配置的服务器,不声称具备沙箱隔离。支持不受信任的二进制文件需要后续补充允许读取工作区并写入私有缓存与临时目录的进程/文件系统契约;受限、远程或虚拟工作区需要另一种提供方。 + +## 备选方案 + +**照搬 Claude Code 的统一 schema。** 它的光标操作验证了核心场景,但符号与调用层级需要不同参数。照搬九种操作会固化尚未验证的接口,因此本提案只对齐四种语义查询。 + +**允许提供方注册工具。** 已加载服务器会控制模型 schema 和提示词,无法在本地与远程提供方之间维持统一契约。 + +**公开任意 LSP 方法。** JSON-RPC 逃生口会泄露协议载荷,并允许未经评审的变更或命令执行;操作联合保持封闭。 + +**公开 `resolve(request)` / `query(spec)`。** 没有需要填充默认值的字段时,resolve 只会暴露提供方选择,而公开 spec 可能活过提供方释放或替换。单一操作让选择与调用共用注册生命周期。 + +**将信号包装为每服务边界的执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。 + +**通过 `ctx.fs` 或 `read` 工具读取。** 这可能把文档与另一文件系统命名空间中的服务器索引混合;工具输出还带窗口、行号且已被观察。host-local 提供方在子进程旁读取未观察的完整文本。 + +**保持文档打开。** 镜像编辑需要版本归属、覆盖所有路径的 `didChange`、HMR 恢复、淘汰和陈旧状态规则。临时打开避免在 MVP 引入这套状态机。 + +**配置分阶段超时。** 嵌套定时器会产生相互竞争的分类与新预算。一个由调用方负责的截止时间覆盖查询;只有调用外清理保留本地限制。 + +**不发送 `didOpen`。** 协议虽允许,但支持不一致且可能使用陈旧服务器状态。临时打开提供明确的当前快照。 + +**增加路由或选择首个匹配项。** 注册顺序与 HMR 时机不是产品语义,路由表又会重复唯一扩展名所有权。因此,扩展名重叠时注册失败。 + +**在一个实例中并发查询。** 取消失败时,终止共享进程会杀死无关工作。实例内串行可限制影响范围;不同实例仍可并行。 + +**内置 preset 或 PATH 发现。** 目录会让通用 host 承担语言策略,而发现机制无法推断参数、语言 id 或初始化配置。部署显式配置提供方,组合插件可以封装 preset。 + +## 测试 + +- 包测试固定三个包的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。 +- 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 +- 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。 +- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `findReferences` 到 `references.includeDeclaration` 的映射。 +- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、打开写入阻塞与失败、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。 +- 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。 +- 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及释放后完全停稳。 +- 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。 +- 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。 +- 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。 +- 包与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` 包组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。 + +## 影响 + +各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的“索引完成”信号。无法声明兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。 + +临时打开会重复解析并产生通知。实例内串行会增加并发 agent 的延迟,长期运行的工作区进程则持续占用内存直到释放。 + +同一运行时内的扩展名所有权互斥。即使 language id 不同,两个提供方也不能同时占用 `.ts`;这是有意接受的 MVP 限制。预期扩展方式是在注册之上增加由部署配置的 selector,允许放宽互斥占用,同时不向模型输入增加提供方选择,也不改变 `LspProvider.query`。 + +UTF-16 光标列与协议完全一致,但模型难以在包含非 BMP 字符的文本中准确计数。无效位置或不在符号上的位置可能返回空结果,因此错误文本和提示词示例必须说明坐标约定,同时避免鼓励模型广泛使用 LSP。 + +直接访问 Node 文件系统会对齐查询快照与服务器索引,但绕过 `ctx.fs` 及其策略。规范路径 containment 会拒绝工作区外的源文件;受信任的服务器仍可读取工作区并使用缓存。因此,第一版要求受信任的 host-local 部署,不提供沙箱保证。 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-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml new file mode 100644 index 0000000000..60f33a0e1e --- /dev/null +++ b/.agents/notes/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: 7d1fb1ad5a2e7563bdddffde1f49368b9f0c13f7 +2026-07-19-package-invariant-runtime-contracts.zh.md: 669eb02221aea4b0654497bb81327d725648dabe 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 new file mode 100644 index 0000000000..7d1fb1ad5a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -0,0 +1,78 @@ +# Agent Note: Meaningful package invariant contracts + +Status: implemented + +English | [中文](2026-07-19-package-invariant-runtime-contracts.zh.md) + +## Problem + +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. + +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. + +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 + +### Registration is exhaustive; assertions must be meaningful + +Every workspace package publishes a separately built `./invariant` companion and registers its exact npm package name. A companion does one of two things: + +- 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. + +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 103-package workspace has 21 executable companions and 82 justified empty companions. + +| Owner | Runtime relationship | +|---|---| +| `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` | 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 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. | +| `dsh-hook-protocol` | Hook invocation/result correlation, dialect, identity, and duration constraints. | +| `dsh-sandbox-policy` | Durable `sandbox/mode` events use the closed sandbox-mode vocabulary. | +| `dsh-fs` | Filesystem decision/observation events carry usable target and version identities. | +| `dsh-goal` | Durable goal snapshots preserve source attribution, rendered content, revisions, lifecycle and timestamp relationships, and sequential admitted rounds. | +| `dsh-goal-session` | Goal-sourced continuation messages match the prompt reconstructed from the preceding durable goal state. | +| `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 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 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 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 + +- **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 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 new file mode 100644 index 0000000000..669eb02221 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -0,0 +1,78 @@ +# Agent Note: 有意义的包不变量契约 + +Status: implemented + +[English](2026-07-19-package-invariant-runtime-contracts.md) | 中文 + +## 问题 + +包自有不变量接缝让发布和注册实现了全覆盖,但最初的生成基线允许空安装器。后续方案又用针对插件名称、注入、effect、服务方法和固定纯函数示例的通用断言替代这些空实现。这些断言虽然让每个 companion 都能执行,却没有提高系统安全性:TypeScript、Cordis 启动、包测试和模块加载测试已经约束这些形状,而不变量服务应当发现不可能出现的运行时状态。 + +有用的运行时不变量会关联时间上的多个观测,或关联可变数据结构中的多个部分。例如:终止事件没有对应的开始事件、LLM delta 指向未打开的 block,或持久化结果的身份与请求不同。仅确认声明的方法存在、插件名称符合预期,或常量示例仍返回已知值,都不属于这种关系。 + +有些包确实没有可持续观测的关系。纯工具、仅负责组合的包、薄适配器、可执行入口和测试支持包可能仍有重要契约,但类型检查、加载检查、聚焦单元测试或集成测试更适合执行这些契约。强迫这些包添加合成运行时断言,只会让实现围绕通过门禁优化,而不是检测损坏。 + +## 决策 + +### 注册必须全覆盖;断言必须有意义 + +每个 workspace 包都发布单独构建的 `./invariant` companion,并用完整 npm 包名注册。companion 只能采用以下两种形式之一: + +- 安装包自有的事件流或相关可变数据结构检查,并通过绑定的 `fail(message)` 报告器报告违规;或 +- 使用空安装器,并在其声明前写一条该包专属的 `No runtime invariant:` 注释,说明为什么该包没有合理的运行时关系可供观测。 + +空形式是明确的架构结论,不是生成占位符。如果后续包变更引入可变状态或事件协议,就必须用相应检查替换该说明。 + +中央 `dsh-invariants` 服务只负责配置、注册唯一性、子 fiber 生命周期、回滚、释放和归属到包的失败。它不暴露通用插件形状、服务形状或启动断言 helper,也不导入产品包。 + +### 已实施的检查 + +当前 103 个包的 workspace 包含 21 个可执行 companion 和 82 个有理由的空 companion。 + +| 所有者 | 运行时关系 | +|---|---| +| `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-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。 | +| `dsh-hook-protocol` | hook invocation/result 的关联、dialect、身份和 duration 约束。 | +| `dsh-sandbox-policy` | 持久化 `sandbox/mode` 事件必须使用封闭的 sandbox-mode 词表。 | +| `dsh-fs` | 文件系统决策/观测事件必须携带可用的 target 和 version 身份。 | +| `dsh-goal` | 持久化目标快照保持来源归属、渲染内容、修订号、生命周期和时间戳关系,并保证已准入的目标回合连续编号。 | +| `dsh-goal-session` | 目标来源的继续执行消息必须匹配根据此前持久化目标状态重建的提示词。 | +| `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 必须匹配 session 当前打开的 turn、下一个 step 开始前的位置和 elapsed baseline;渲染时间必须可解析,且不得晚于对应事件。 | + +基于 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。产物门禁会按每个包的精确 `npm pack` 文件清单暂存文件,在 plain Node 下导入该包已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,未发布的共享运行时分片会在正式发布前导致门禁失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 + +## 考虑过的替代方案 + +- **保留生成的空 companion。** 拒绝,因为包获得有意义的运行时关系后,没有解释的占位符仍可能继续存在。 +- **要求每个包都执行断言。** 拒绝,因为方法存在性、插件形状和固定示例断言会重复更强的类型、加载和单元测试契约,却没有检查运行时一致性。 +- **在服务中保留通用形状 helper。** 拒绝,因为这会混淆编译期 API 验证和运行时不变量,并鼓励在中央定义产品假设。 +- **把产品检查移入服务。** 拒绝,因为产品词汇、依赖、测试和变更所有权应归属于产生这些数据的包。 +- **从根入口隐式注册 companion。** 拒绝,因为组合顺序和可选服务存在性会产生隐藏 effect。 + +## 后果 + +- 每个包都有可见的所有权与发布 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 new file mode 100644 index 0000000000..a3e8c3ad8a --- /dev/null +++ b/.agents/notes/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: 2443a8f7d04b96f51bb798130078a7457f78b2a1 +2026-07-19-package-owned-invariant-service.zh.md: 3c71d3b7f99a507d4c0236b7ef6dc0794814cdc8 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 new file mode 100644 index 0000000000..2443a8f7d0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md @@ -0,0 +1,105 @@ +# Agent Note: 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. + +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. + +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 + +```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 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. + +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. + +### Initial stateful companions and exhaustive ownership + +| 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 | + +These four owners supplied the initial stateful checks. The follow-up runtime-contract decision adds checks for seventeen 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, 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 + +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 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. + +## 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. 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. + +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 + +- **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. +- **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 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 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 new file mode 100644 index 0000000000..3c71d3b7f9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.zh.md @@ -0,0 +1,105 @@ +# Agent Note: 包拥有的不变式服务接缝 + +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` 伴随插件,注册自己完整且准确的 npm 包名。如果所有者具备有意义的事件或可变数据关系,companion 就检查该关系;否则空 installer 必须携带该所有者专属的说明。后续的[运行时契约 Agent Note](2026-07-19-package-invariant-runtime-contracts.md) 禁止生成的所有权占位符和合成 API 形状断言。包的根入口不会隐式导入或注册诊断,因此加载根包不会改变运行时检查,也不要求不变式服务存在。 + +### 配置与选择 + +```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 返回的 promise,因此异步启动检查仍具有事务性。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` | 模型请求重建 | + +这四个所有者提供了首批有状态检查。后续运行时契约决策为另外十七个确有事件或可变数据关系的所有者增加检查,并为其余包记录有理由的空 companion。每个伴随入口都是单独打包的 `./invariant` export,具有独立声明和对 Loader 安全的命名空间插件形态;服务包自身的伴随插件导入本地服务类型,避免形成自依赖。 + +`verify-package-invariants` 会发现每个工作区包,并拒绝缺失的伴随插件源码、生成标记、没有解释的空 installer、缺少或不使用失败报告器的非空 installer、外部或无法解析的注册名、缺失的 `./invariant` export 或发布文件、缺失的不变式对等依赖(peer dependency)、开发依赖及项目引用,以及遗漏伴随入口的自定义构建配置。 + +### 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 重新注册。具备可执行检查的所有者会把正向与负向行为保留在 companion 源码旁边。 + +组合测试覆盖标准 spine 转发和生成的 SDK 条目。Loader 测试固定每个伴随命名空间,构建后的纯 Node smoke 覆盖编译子路径 export。scoped event 新鲜度门禁会重新执行语义 Program 分析。 + +每个 Vitest 配置都会加载测试宿主;在普通 Cordis 根上下文启动第一个插件之前,宿主会挂载显式启用的服务,并添加当前测试包的伴随插件。一个完整拓扑会一次挂载所有包的伴随插件;服务与所有者的聚焦测试自行构建不变式拓扑,从而在不发生重复所有权冲突的前提下覆盖关闭、过滤、回滚与重载。门禁测试还会执行每个伴随插件的 `apply` 函数,并验证它调用 `register` 时使用包清单中的包名,而不是只检查源码文本。 + +## 考虑过的替代方案 + +- **把所有检查保留在 `dsh-invariants`。** 不予采纳,因为注册包仍要导入所有被检查的产品领域,所有者变更仍需中央编辑,测试也继续远离被保护的契约。 +- **当 `ctx.invariants` 恰好存在时,让根包入口隐式注册检查。** 不予采纳,因为根入口行为会依赖组合顺序与可选服务是否存在,诊断无法独立选择,而且包加载会隐藏一个不在显式伴随插件中的注册 effect。 +- **在运行时自动发现所有 `invariant.ts` 文件。** 不予采纳,因为文件系统或包发现不是运行时所有权契约,会让 bundle 发布含义不清,也无法表达显式 Cordis 加载顺序或依赖安装。构建期生成与校验以及测试 host 可以枚举源码树,因为它们验证的是仓库完整性,而不是组合已发布的部署。 +- **根据当前已加载包集合验证 allow/block 条目。** 不予采纳,因为零匹配模式可能有意指向稍后加载或 HMR 加载的贡献;当前加载顺序不能决定配置有效性。 + +## 后果 + +- 产品包拥有并测试自己的关系断言,服务保持与产品无关。 +- 每个包都承担 companion 的发布与依赖成本;只有具备有意义运行时关系的所有者才增加 listener 或 trace 状态成本。 +- 标准组合无需改变插件树即可关闭全部检查或按包名选择。 +- 显式伴随条目让诊断成本和所有权在 Cordis 配置与包 export 中可见。 +- 每个选中的可执行贡献增加一个子 fiber 及其 listener/状态成本;选中的空贡献不增加 listener 或 trace 状态成本,被过滤注册则只保留包名占用。 +- 正则表达式源属于部署配置,在服务重载前保持固定。 +- 普通 Vitest 根上下文会安装当前测试包中被选中的伴随插件;一个完整拓扑只支付一次全部子 fiber 成本,用于覆盖整个仓库的注册。 +- 会话存储验证、快照、冻结、provenance 与 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/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml new file mode 100644 index 0000000000..e6b0fa166a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.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-21-compaction-summary-prefix-cache-reuse.md: 490eb57a5891bf9cd0799c5d49d25d4e9838041f +2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: 02412ff07e87e12c7e7de00b5c69e1282433f735 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md new file mode 100644 index 0000000000..490eb57a58 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md @@ -0,0 +1,45 @@ +# Agent Note: The summarization call replays the conversation prefix for KV-cache reuse + +Status: implemented + +English | [中文](2026-07-21-compaction-summary-prefix-cache-reuse.zh.md) + +## Problem + +Automatic compaction fires mid-conversation, right after the loop has warmed the provider's KV cache with the last routed request (`system` + `tools` + `messagePrefix` + derived history). The default summarizer then issued a *separate* auxiliary request whose prefix shared nothing with that warm request: a bespoke summarizer `system` prompt followed by the older history flattened to a single rendered transcript string. A provider caches on the request's leading token sequence, so a first token that differs — a different system prompt — invalidates the entire cached prefix. Every compaction therefore paid full prompt-processing cost for the whole replayed history twice: once for the conversation request that tripped pressure, and again for the summarization call, defeating the cache exactly when the conversation is largest. + +## Decision + +The summarization directive moves from the **front** of the request (a fresh `system` prompt) to the **end** of the conversation (the final `user` message). The auxiliary call now reproduces the last routed request's prefix verbatim and appends one trailing instruction, so it is a genuine prefix-extension of the warm request and the provider reuses the cached tokens. + +### `SummarizationInput` carries the replayed prefix, not a rendered string + +`summarize()` (and the internal `summarizeWithLlm`) take a `SummarizationInput` — `{ system?, tools?, messages }` — instead of a flat transcript string. `region.ts` builds it from `session.requestHeader()` (the durable `system`, `tools`, and `messagePrefix`) plus the shadowed region mapped through `session.deriveEventMessage`, which yields byte-identical `Message` objects to what `deriveMessages()` folded into the routed request. `summarizeWithLlm` forwards `system` and `tools` onto `GenerateOptions` and sends `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`. `tools` ride along even though the summarizer never calls one: dropping them would shorten the token sequence and break alignment with the cached request. + +### The instruction is a trailing user message + +`COMPACTION_INSTRUCTION` opens "You are now acting as a compaction engine…" and directs the model to condense *the conversation ABOVE*. It keeps the prior checkpoint's structured headings and adds two rules the front-loaded system prompt did not need in its new position: do not mention the summarization request, and output only the checkpoint text without calling a tool. The shadowed region always ends on a tool-pairing-balanced boundary, so appending a `user` message after it is a valid message ordering for OpenAI-compatible and DeepSeek adapters. + +### Cache reuse is best-effort, correctness is not + +Auto-compaction always anchors at the surface head, so the shadowed region is the head of the routed request and the replayed prefix matches it exactly — the guaranteed-hit case. Manual mid-range `compactRegion` still replays the true prefix and stays correct, but forgoes reuse because its shadowed region is not the request head. A configured `summarizationProvider`/`summarizationModel` that differs from the conversation's route also forgoes reuse; that is the deployment's explicit trade-off, not a defect. Target resolution (configured override → latest routed header → agent options, else throw) is unchanged. + +## Alternatives considered + +- **Keep the summarizer system prompt but reuse the rest** — rejected: the system slot is the very first token region a provider caches on, so a distinct summarizer system prompt invalidates the whole prefix regardless of what follows. Only moving the directive off the front recovers the cache. +- **Send only the shadowed region without the `system`/`tools`/`messagePrefix` head** — rejected: a shorter or differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs. +- **Omit `tools` from the summarization request** (the model never calls one) — rejected: tool schemas are part of the cached token sequence; omitting them misaligns every following token and defeats reuse. +- **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — out of scope here; the replay gap predates this change and is tracked in the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md). + +## Consequences + +- **`dsh-compact-basic`** owns `SummarizationInput`; the protected `summarize(input, agent, signal?)` hook signature changed (acceptable pre-release), and `region.ts` gained `buildSummarizationInput` folding `deriveEventMessage` over the shadowed seqs behind the header prefix. +- **Dead render surface removed.** The old flattening path (`renderTranscript` / `renderContentBlocks` and its spec in `dsh-compact`) had no remaining consumer and was deleted with its export. +- **README model experience** for `dsh-compact-basic` now documents the auxiliary request as the replayed prefix plus a trailing compaction-instruction message, and its KV-cache effect as reuse of the warm conversation prefix. +- **The framed checkpoint output is unchanged**, so the landed `user/message` and every conversation-request snapshot are unaffected; only the auxiliary request's shape changed. + +## Testing + +- **Unit:** `compact-basic.spec.ts` asserts the auxiliary call forwards `system`/`tools`/leading messages and appends the compaction instruction as the final message, and that `compactRegion` replays the latest routed header prefix. Existing content assertions read the summarizer input through the replayed messages rather than a transcript string. +- **Loop:** `compact-loop-repro.spec.ts` classifies the summarization request by the compaction instruction in its trailing user message, and the overflow-recovery tests continue to pin conversation-vs-summary request counts across the real loop. +- **Snapshot gap unchanged:** the summarization call still emits no `assistant/chunk` events, so it remains outside keyless replay; the pre-existing gap is owned by the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md). diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md new file mode 100644 index 0000000000..02412ff07e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 摘要调用回放对话前缀以复用 KV 缓存 + +Status: implemented + +[English](2026-07-21-compaction-summary-prefix-cache-reuse.md) | 中文 + +## Problem + +自动压缩(compaction)在对话中途触发,恰好在循环用最后一个已路由请求(`system` + `tools` + `messagePrefix` + 派生历史)预热了提供方的 KV 缓存之后。随后默认摘要器发出一个*独立的*辅助请求,其前缀与那个已预热请求没有任何共享部分:一个专门的摘要器 `system` 提示词,后接被拍平成单个渲染后 transcript(文本记录)字符串的较早历史。提供方基于请求起始的 token 序列做缓存,因此第一个 token 只要不同(即一个不同的系统提示词),整个已缓存前缀就会失效。于是每次压缩都要为整段回放的历史付出两次完整的提示词处理成本:一次用于触发压力的对话请求,另一次用于摘要调用,恰好在对话最大时让缓存失去作用。 + +## Decision + +摘要指令从请求的**前端**(一个全新的 `system` 提示词)移到对话的**末尾**(最后一条 `user` 消息)。辅助调用现在逐字复现最后一个已路由请求的前缀,并追加一条尾部指令,因此它是已预热请求的真正前缀扩展,提供方会复用已缓存的 token。 + +### `SummarizationInput` 携带回放的前缀,而非渲染后的字符串 + +`summarize()`(以及内部的 `summarizeWithLlm`)接受一个 `SummarizationInput`(`{ system?, tools?, messages }`)而不是一个扁平的 transcript 字符串。`region.ts` 用 `session.requestHeader()`(持久的 `system`、`tools` 和 `messagePrefix`)加上经 `session.deriveEventMessage` 映射的被遮蔽区域来构建它,后者产出与 `deriveMessages()` 折叠进已路由请求的内容字节级一致的 `Message` 对象。`summarizeWithLlm` 把 `system` 和 `tools` 转发到 `GenerateOptions`,并发送 `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`。`tools` 会一同带上,即便摘要器从不调用任何工具:丢弃它们会缩短 token 序列,破坏与已缓存请求的对齐。 + +### 指令是一条尾部 user 消息 + +`COMPACTION_INSTRUCTION` 以 "You are now acting as a compaction engine…" 开头,指示模型浓缩*上方的对话*。它保留先前检查点的结构化标题,并在其新位置上新增了两条前置系统提示词此前不需要的规则:不要提及摘要请求,以及只输出检查点文本而不调用任何工具。被遮蔽区域总是结束在工具配对平衡的边界上,因此在其后追加一条 `user` 消息,对 OpenAI 兼容适配器和 DeepSeek 适配器而言是合法的消息排序。 + +### 缓存复用是尽力而为,正确性不是 + +自动压缩总是锚定在表层头部,因此被遮蔽区域就是已路由请求的头部,回放的前缀与之完全匹配,这就是保证命中的情形。手动的中段 `compactRegion` 仍然回放真实的前缀并保持正确,但会放弃复用,因为它的被遮蔽区域不是请求头部。配置的 `summarizationProvider`/`summarizationModel` 若与对话的路由不同,也会放弃复用;这是部署方明确的权衡,而非缺陷。目标解析(配置的覆盖值 → 最新的已路由 header → agent(智能体)选项,否则抛出)保持不变。 + +## Alternatives considered + +- **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。 +- **只发送被遮蔽区域而不带 `system`/`tools`/`messagePrefix` 头部**——否决:更短或头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。 +- **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。 +- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——此处超出范围;该回放缺口早于本次改动,记录在 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中。 + +## Consequences + +- **`dsh-compact-basic`** 拥有 `SummarizationInput`;受保护的 `summarize(input, agent, signal?)` 钩子签名发生变化(发布前可接受),并且 `region.ts` 新增了 `buildSummarizationInput`,它在 header 前缀之后对被遮蔽的 seq 折叠 `deriveEventMessage`。 +- **移除无用的渲染表面。** 旧的拍平路径(`renderTranscript` / `renderContentBlocks` 及其在 `dsh-compact` 中的 spec)已无消费方,连同其导出一并删除。 +- **README 的 Model Experience** 现在把 `dsh-compact-basic` 的辅助请求记述为回放的前缀加上一条尾部压缩指令消息,并把其 KV 缓存效果记述为复用已预热的对话前缀。 +- **带框架的检查点输出未改变**,因此落地的 `user/message` 和每个对话请求快照都不受影响;只有辅助请求的形状发生了变化。 + +## Testing + +- **单元:** `compact-basic.spec.ts` 断言辅助调用转发 `system`/`tools`/前导消息,并把压缩指令作为最后一条消息追加,且 `compactRegion` 回放最新的已路由 header 前缀。现有的内容断言通过回放的消息而非 transcript 字符串来读取摘要器输入。 +- **循环:** `compact-loop-repro.spec.ts` 依据摘要请求尾部 user 消息中的压缩指令对其分类,溢出恢复测试则继续在真实循环中固定对话请求与摘要请求的数量。 +- **快照缺口未变:** 摘要调用仍然不发出 `assistant/chunk` 事件,因此它仍处于无密钥回放之外;这一既有缺口归 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.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 99313866ed..5b7381be1d 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 @@ -8,7 +8,7 @@ A long-running agent conversation grows without bound. As the event log accumula The [session surface](../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](../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](../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 @@ -31,7 +31,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold. -`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. +`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). ### Automatic pressure runs after successful durable step work @@ -53,7 +53,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. @@ -65,7 +65,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 @@ -115,7 +115,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`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. `dsh-invariants` treats fresh appended tool results as executions that require an open step and pending call; validated replacements remain turn-enclosed rewrites. +- **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call; validated replacements remain turn-enclosed rewrites. - **Wiring**: `examples/tui-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy. ## Testing diff --git a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md index 1c5b5b533d..d1b61b0af8 100644 --- a/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -20,7 +20,11 @@ The client advertises NO optional capabilities (no `fs`, no `terminal`): the chi ### No start-time capabilities -The provider's `capabilities` are all `false`. An out-of-process child cannot honor the parent's `maxDepth` (it has no access to `parent.options.subagentDepth`) or `toolFilter` (it owns its own tool registry), and the first cut does not implement `outputSchema`. The service rejects a request needing any of them before `start` runs. The backend injects only `subagents` (not `ctx.agents`) and ignores `request.parent`. +The provider's `capabilities` are all `false`. An out-of-process child cannot honor the parent's `maxDepth` (it has no access to `parent.options.subagentDepth`) or `toolFilter` (it owns its own tool registry), and the first cut does not implement `outputSchema`. The service rejects a request needing any of them before `start` runs. The backend injects only `subagents` (not `ctx.agents`); the ONE thing it reads off `request.parent` is the session header's cwd (see the workspace resolution below) — no conversation context, depth, or tool state crosses the process boundary. + +### Workspace cwd resolution + +The child's working directory is an explicit resolution, never the harness process cwd: the deployment `cwd` override when configured (made absolute against the launch directory and validated at load), else the parent session header's cwd (validated at start), and a loud rejection before anything spawns when neither exists. One ACP server process serves sessions from many workspaces, so `process.cwd()` cannot stand in for a session's workspace — the old implicit fallback ran children in the server's launch directory. A candidate must be an absolute path naming a directory the harness can ENTER (`X_OK` — `statSync().isDirectory()` alone accepts a mode-600 directory that spawn would fail with EACCES), and the same resolved path becomes both the subprocess cwd and the ACP `session/new` workspace. ### StopReason mapping @@ -33,6 +37,7 @@ The child is a separate process, so it inherits an environment. Credential-shape ## Testing - **Keyless unit/integration:** A scripted ACP subprocess exercises real stdio for prompt/output flow, every stop-reason mapping, signal and disposal cancellation (including pre-abort, pre-session race, and torn-pipe cases), both permission policies, ignored non-message updates, missing-command cleanup, provider reload, and namespace exports. +- **Keyless Loader composition:** A test-only cordis.yml boots the stdio app through the real Loader with the backend's `cwd` omitted; a scripted model delegates once and the scripted child proves it ran in — and was announced — the parent session's workspace (the cwd-inheritance branch end to end). - **With-key e2e:** The backend spawns the real ACP example; its model answers `PONG`, writes `proof.txt`, and the parent verifies the file. - **Snapshot gap:** Each ACP child is a separate process with its own replay session, unlike in-process per-session replay. Deterministic mock-server coverage exists, while `TODO(acp-subagent-replay)` tracks parent replay against a replaying child. diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md b/.agents/notes/implemented/feature/2026-07-07-session-prefix.md index c2f58dac0c..7faf80cf80 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md +++ b/.agents/notes/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 Agent Note 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 Agent Note 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 Agent Note](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/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index bc77edbd0a..810f8863e5 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.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-17-dedicated-full-screen-tui-front-door.md: 8fbc5dddc029190b346075a65c9e7857187f3d2b -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6ddc3523b7a7173013efe2ef15c5ca0e940929fb +2026-07-17-dedicated-full-screen-tui-front-door.md: 3e2b1e751001020eccc9193438daff23dd42518a +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 5f3632f43702e16ca9dec07c0bb8e42bf50499b7 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index 8fbc5dddc0..3e2b1e7510 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -20,9 +20,11 @@ The selected front door receives the exact generated or resumed `SessionId` used ### Session projection and interaction -The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Pending chunks and tool calls update the same components that completed events settle. +The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. -Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The plugin registers the shared `userInteraction` provider and presents questions as queued keyboard overlays; agent behavior and answer logging remain owned by their existing services. +Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and pairs the selected model with its reasoning state; during a run, elapsed activity and the Escape interrupt hint replace that summary. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. + +The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model pair per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. ### Terminal ownership @@ -39,6 +41,7 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t - **Keep readline and full-screen modes inside `@deepseek-ai/dsh-stdio`** — rejected because line-oriented output and differential TTY rendering have different dependencies, input rules, logging ownership, and teardown obligations. Separate packages keep the pipe-safe contract small and explicit. - **Let the TUI plugin silently downgrade when either stream is not a TTY** — rejected because a fallback hides deployment mistakes and changes interaction semantics. The app bundle may select a front door with `auto`; an explicitly mounted TUI fails loud. - **Keep TUI wiring and tests under the readline `repl-agent` leaf** — rejected because one leaf would represent two distinct front doors and break symmetry with `acp-agent`. A dedicated `tui-agent` leaf owns TUI overlays and tests while reusing the repl-agent backend composition. +- **Mutate `agent.options` when `/model` runs** — rejected because creation options do not provide an atomic boundary between asynchronous prompt assembly and request routing. Agent-scoped waterfalls preserve immutable creation input and snapshot the selected pair for each step. ## Consequences @@ -46,3 +49,4 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t - The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol. - Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor. - Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. +- Model selection uses adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 6ddc3523b7..5f3632f437 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -20,9 +20,11 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README ### 会话投影与交互 -TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 +TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 -agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。插件注册共享的 `userInteraction` 提供方,以排队的键盘浮层呈现问题;agent 行为和答案日志仍由既有服务负责。 +agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并将选中模型及其推理状态组合显示;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 + +`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个 step 快照一次同一个提供方/模型字段组合,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 ### 终端所有权 @@ -39,6 +41,7 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 - **把 readline 与全屏模式都保留在 `@deepseek-ai/dsh-stdio` 中**:不予采纳,因为逐行输出和差分 TTY 渲染具有不同的依赖、输入规则、日志所有权和资源清理义务。拆分为独立包可以让管道安全契约保持精简、明确。 - **当任一进程流不是 TTY 时,让 TUI 插件静默降级**:不予采纳,因为回退会掩盖部署错误并改变交互语义。应用包可以通过 `auto` 选择入口;明确挂载的 TUI 会快速失败。 - **把 TUI 接线与测试保留在 readline `repl-agent` 叶节点下**:不予采纳,因为一个叶节点会代表两个不同入口,也会破坏它与 `acp-agent` 的对称性。独立的 `tui-agent` 叶节点负责 TUI 浮层和测试,同时复用 repl-agent 的后端组合。 +- **在 `/model` 运行时修改 `agent.options`**:不予采纳,因为创建选项无法在异步 prompt 组装与请求路由之间提供原子边界。agent 作用域内的 waterfall 会在保持创建输入不可变的同时,为每个 step 快照一次选中的字段组合。 ## 后果 @@ -46,3 +49,4 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 - TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。 - 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 - 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 +- 模型选择使用适配器提供的目录元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 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/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml index 366faaf6a3..b56814b9a3 100644 --- a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.i18n.yaml +++ b/.agents/notes/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: f9c00a4b6a5e9f08c11902e9267e4c1a954cebf8 -2026-07-14-typescript-program-backed-semantic-gates.zh.md: ce1f1edc765f621ca9f650720aa2db43f636e330 +2026-07-14-typescript-program-backed-semantic-gates.md: 43a7b9b5369feb199721f5f1348c03cde66ee411 +2026-07-14-typescript-program-backed-semantic-gates.zh.md: 1ab027d723e30007e6675ae1f3589fb594d10afc diff --git a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md index f9c00a4b6a..43a7b9b536 100644 --- a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md +++ b/.agents/notes/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/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md b/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md index ce1f1edc76..1ab027d723 100644 --- a/.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.zh.md +++ b/.agents/notes/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/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.md b/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.md index 91679a0a6c..500be241c6 100644 --- a/.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.md +++ b/.agents/notes/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 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 Agent Note 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 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/.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/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml new file mode 100644 index 0000000000..e1ad20997d --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,83 @@ +name: Deploy documentation + +on: + push: + branches: [master] + 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/AGENTS.md b/AGENTS.md index 6a0e4ca13b..e2fc77211a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools + lsp/ language-server seam + local stdio provider + model-facing lsp tool skill/ skill provider registry + local impl + catalog/loader tool web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend @@ -98,6 +99,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)). diff --git a/docs/architecture.md b/docs/architecture.md index 0c3e55f269..09dad5bbfc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,9 +4,9 @@ The **DeepSeek Harness SDK** builds on Cordis: **everything is a plugin**, inclu ## Overview -Harnesses are [Cordis](cordis-primer.md) contexts. Packages contribute services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable prompts, tools, providers, adapters, and listeners. +Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute disposable services, events, and registrations. -`packages/core/` groups the default agent flow; surrounding capabilities are equally first-class Cordis plugins. +`packages/core/` groups the default agent flow. ### Default Services @@ -30,6 +30,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts. Packages contribute services | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events | +| `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | semantic navigation registry | | `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | | `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction; optional model-free result pruning | @@ -39,6 +40,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts. Packages contribute services | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | | `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 @@ -136,7 +138,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](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). +**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)). Durability is a plugin concern. Backends buffer synchronous `session/event` notifications; the loop awaits a turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract. diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 4b60089577..0cb39f9041 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -26,6 +26,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"] @@ -124,6 +126,7 @@ flowchart LR pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs pkg_goal --> svc_goals + pkg_invariants --> svc_invariants pkg_llm --> svc_llm pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm @@ -163,7 +166,6 @@ flowchart LR svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_cli_demo - svc_agents --> pkg_invariants svc_agents --> pkg_subagent_inprocess svc_agents --> pkg_tui_demo svc_approval --> pkg_tool_bash @@ -176,6 +178,10 @@ flowchart LR svc_commands --> pkg_tui 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 @@ -191,7 +197,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 @@ -232,7 +237,8 @@ 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.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | -| `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. | @@ -240,7 +246,7 @@ flowchart LR | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`tui`](../packages/ui/tui), [`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.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Plugins register direct human commands; TUI and ACP consume the same effective per-agent catalog without sending invocations to the model. | | `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), [`tui-demo`](../packages/examples/tui-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), [`tui-demo`](../packages/examples/tui-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.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5b02a79fd2..36d2b69485 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:247`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:252`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -122,8 +122,9 @@ Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loo * skill registry/local provider/tool consumer, `workspaceContext` to the * workspace-context loader, `llmRetry` to the bounded request-recovery policy, * and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. - * `goals` opts into and configures the persisted goal - * domain plus its model tool and same-session driver. Owner schemas supply defaults for optional input; + * `goals` opts into and configures the persisted goal domain plus its model tool + * and same-session driver; `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 @@ -150,6 +151,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false + /** Global enablement and package-name filters for invariant companions. */ + invariants?: InvariantConfig /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ goals?: GoalConfig | false /** Bounded transient model-request retry policy. */ @@ -177,9 +180,9 @@ export interface GoalConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`llmRetry`](../packages/llm/llm-retry/src/index.ts) · [`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) · [`toolGoal`](../packages/goal/tool-goal/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) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`llmRetry`](../packages/llm/llm-retry/src/index.ts) · [`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) · [`toolGoal`](../packages/goal/tool-goal/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:73`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:78`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -303,15 +306,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 @@ -319,12 +332,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-compact-tool-result-prune` @@ -447,6 +466,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` @@ -503,6 +538,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 } ``` @@ -589,10 +626,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` @@ -616,6 +655,46 @@ export interface Config { Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src/index.ts) +## `@deepseek-ai/dsh-lsp-local` + +Requires: `lsp` + +```ts config-catalog +/** Plugin configuration: provider id → local language-server configuration. */ +export interface Config { + /** Non-empty table of stable provider ids to independent local server configurations. */ + servers: Record +} + +/** One configured local language server and its host bounds. */ +export interface LspLocalServerConfig { + /** Executable to spawn (absolute, or resolved on PATH at load). */ + command: string + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + extensionToLanguage: Record + /** Arguments passed to the executable (no shell). Default `[]`. */ + args?: string[] + /** Extra env vars merged on top of the scrubbed ambient env. Default `{}`. */ + env?: Record + /** Static `initialize` options forwarded to the server. Default `null`. */ + initializationOptions?: unknown + /** Static answer to every `workspace/configuration` item. Default `null`. */ + configuration?: unknown + /** Largest single framed message accepted from the server (bytes). Default 16000000. */ + maxMessageBytes?: number + /** Largest stderr tail retained for diagnostics (bytes). Default 1000000. */ + maxStderrBytes?: number + /** Largest source file this host will open (bytes). Default 4000000. */ + maxDocumentBytes?: number + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ + shutdownTimeoutMs?: number + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ + killGraceMs?: number +} +``` + +Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) + ## `@deepseek-ai/dsh-mcp-client` Requires: `tools` @@ -782,7 +861,7 @@ export interface Config { Depends on: [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:39`](../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:44`](../packages/sandbox/sandbox-policy/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` @@ -938,8 +1017,11 @@ export interface Config { /** Arguments passed to {@link command}. */ args: string[] /** - * Working directory for the child process and its ACP session. Defaults to - * the parent process's cwd when omitted. + * Working directory override for the child process and its ACP session. + * Must be non-empty; a relative path resolves against the harness launch + * directory at load, and the result must be an existing directory. When + * omitted, each child inherits its delegating parent session's cwd — and + * starting one from a parent session that has no cwd fails. */ cwd?: string /** @@ -969,7 +1051,7 @@ export interface Config { export type PermissionPolicy = 'allow' | 'reject' ``` -Source: [`packages/subagent/subagent-acp/src/index.ts:18`](../packages/subagent/subagent-acp/src/index.ts) +Source: [`packages/subagent/subagent-acp/src/index.ts:21`](../packages/subagent/subagent-acp/src/index.ts) ## `@deepseek-ai/dsh-subagent-fork` @@ -1039,11 +1121,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 = Record ``` Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts) @@ -1138,6 +1217,24 @@ export interface Config { Source: [`packages/goal/tool-goal/src/index.ts:27`](../packages/goal/tool-goal/src/index.ts) +## `@deepseek-ai/dsh-tool-lsp` + +Requires: `tools` · `lsp` · `systemPrompt` + +```ts config-catalog +/** Plugin configuration: result caps and the timeout budget. */ +export interface Config { + /** Largest number of rendered locations before an omission marker (default 100). */ + maxLocations?: number + /** Largest complete rendered result in characters, including truncation metadata (default 16000). */ + maxResultChars?: number + /** Tool-call timeout budget in ms (default 60000). */ + timeoutMs?: number +} +``` + +Source: [`packages/lsp/tool-lsp/src/index.ts:58`](../packages/lsp/tool-lsp/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflows` · `subagents` · `systemPrompt` @@ -1306,7 +1403,7 @@ Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/inde ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `commands` · `userInteraction` · `tools` +Requires: `agents` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1321,14 +1418,20 @@ export interface Config extends TuiConfig { export interface TuiConfig { /** Render model reasoning blocks. */ showReasoning?: boolean - /** Maximum tool-output lines shown before the card is collapsed. */ + /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number - /** Maximum options visible at once in a user-question dialog. */ + /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number - /** User-question dialog width in terminal columns. */ + /** Maximum models visible at once in the model selector. */ + maxModelOptions?: number + /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number - /** User-question dialog maximum height in terminal rows. */ + /** User-question panel maximum height in terminal rows. */ questionDialogMaxHeight?: number + /** Model-selector width in terminal columns. */ + modelDialogWidth?: number + /** Model-selector maximum height in terminal rows. */ + modelDialogMaxHeight?: number /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean /** Apply the built-in ANSI color palette. */ @@ -1338,7 +1441,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:103`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:127`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` @@ -1585,8 +1688,8 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/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-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/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-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a213c9e11a..9274f1eb00 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -536,7 +536,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:44`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:52`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d27b483399..daac7aa960 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:223`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:224`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -422,7 +422,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionTrigger](../core-data-structures/compaction.md) -Source: [`packages/compact/compact/src/index.ts:40`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:39`](../../packages/compact/compact/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) @@ -613,6 +613,24 @@ Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-d Source: [`packages/goal/goal/src/index.ts:135`](../../packages/goal/goal/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 - 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: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. @@ -642,6 +660,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 @@ -657,9 +685,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:137`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:159`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -747,7 +775,7 @@ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy Types: [SandboxExecutionPolicy](../core-data-structures/sandbox.md) · [SandboxPolicyRequest](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:63`](../../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../../packages/sandbox/sandbox-policy/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) @@ -1261,7 +1289,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.toolResultPrune` — `ToolResultPruneService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 888cd145b3..b4a8a7ad7d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -31,6 +31,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv`, enforcement and fail-closed errors | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | | [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` | +| [lsp.md](lsp.md) | the LSP navigation seam: `LspQueryRequest`/`Result`, `LspProvider`/`Service`, four operations, `LspError` | | [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading | | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface | | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | @@ -192,6 +193,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 674bff9dc8..257ce90cda 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -154,7 +154,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 /** @@ -178,6 +178,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/core-data-structures/lsp.md b/docs/core-data-structures/lsp.md new file mode 100644 index 0000000000..eb370f6e38 --- /dev/null +++ b/docs/core-data-structures/lsp.md @@ -0,0 +1,163 @@ +# LSP navigation + +The LSP seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) exposing semantic code navigation on one `ctx.lsp` service, split across packages: interface ([dsh-lsp](../../packages/lsp/lsp), `ctx.lsp` + the provider registry), a generic implementation ([dsh-lsp-local](../../packages/lsp/lsp-local), a configured stdio language-server host), and consumer ([dsh-tool-lsp](../../packages/lsp/tool-lsp), the `lsp` tool schema). LSP is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A provider swap does not change how the model asks for navigation. + +Source: [`packages/lsp/lsp/src/types.ts`](../../packages/lsp/lsp/src/types.ts) + +## Operations and coordinates + +The seam and model expose exactly four semantic queries; the union is closed, so adding one is a compile-enforced change across the seam, providers, and the tool. Positions and ranges are zero-based UTF-16, matching the protocol; the model-facing tool owns the one-based cursor convention and converts on the way in and out. + +```ts type-equiv +/** + * The four semantic queries the seam and model expose. A closed union: adding an operation is a + * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are + * deliberately deferred (they need different schemas). + */ +type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' +``` + +```ts type-equiv +/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */ +interface LspPosition { + /** Zero-based line. */ + readonly line: number + /** Zero-based UTF-16 code-unit offset within the line. */ + readonly character: number +} +``` + +```ts type-equiv +/** A zero-based UTF-16 half-open range `[start, end)`. */ +interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} +``` + +## Request + +Every field is required: `workspaceRoot` is caller-supplied, `languageId` comes from the provider's registration (not the request), and consumers own timeouts and result limits — so no field needs implementation defaulting and there is no `resolve()` step. The provider receives the caller's request plus the derived `languageId`, which only synchronizes the transient document and never participates in selection. + +```ts type-equiv +/** + * A caller's normalized query. Every field is required: `workspaceRoot` is caller-supplied, + * `languageId` comes from the provider registration (not here), and consumers own timeouts and + * result limits — so no field needs implementation defaulting and there is no `resolve()` step. + */ +interface LspQueryRequest { + /** Which semantic query to run. */ + readonly operation: LspOperation + /** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */ + readonly filePath: string + /** The zero-based UTF-16 cursor position to query at. */ + readonly position: LspPosition + /** The workspace root the provider resolves against and indexes; required, never defaulted. */ + readonly workspaceRoot: string +} +``` + +```ts type-equiv +/** + * A request as a provider receives it: the caller's {@link LspQueryRequest} plus the `languageId` + * the seam derived from the provider's extension mapping. The language id only synchronizes the + * transient document; it does not participate in selection. + */ +interface LspProviderQuery extends LspQueryRequest { + /** The LSP language id for `filePath`, from this provider's extension mapping. */ + readonly languageId: string +} +``` + +## Result + +A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `findReferences` always includes declarations — the provider enforces this internally, so callers get no flag. The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to, so a caller relativizing display paths uses it rather than the possibly-symlinked request root. + +```ts type-equiv +/** One resolved location: a document URI and the range within it. */ +interface LspLocation { + /** The target document URI (`file:` or otherwise), verbatim from the server. */ + readonly uri: string + /** The range within the target document. */ + readonly range: LspRange +} +``` + +```ts type-equiv +/** Normalized hover content, or `null` for no hover at the position. */ +interface LspHover { + /** The normalized hover text (markdown or plaintext, provider-joined). */ + readonly contents: string + /** The range the hover applies to, when the server supplied one. */ + readonly range?: LspRange +} +``` + +```ts type-equiv +/** + * The closed result union. Navigation operations (`goToDefinition`, `findReferences`, + * `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`. + * Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. + * + * The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the + * request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that + * relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`; + * otherwise a symlinked workspace misclassifies in-workspace results as external. + */ +type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string } + | { readonly kind: 'hover'; readonly hover: LspHover | null } +``` + +## Provider and service + +A provider owns a stable branded `id` and an exclusive lowercase leading-dot extension map. `registerProvider` reserves the id and every extension atomically — an invalid or conflicting registration publishes nothing — and its disposer releases all reservations. Selection is per query and order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. The seam exposes no protocol types, process/document controls, or generic JSON-RPC escape hatch. + +```ts type-equiv +/** + * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link + * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). + * `findReferences` always includes declarations — the provider enforces this internally; callers + * get no flag. + */ +interface LspProvider { + /** Stable provider identity, reserved atomically with the extension mappings. */ + readonly id: LspProviderId + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + readonly extensionToLanguage: Readonly> + /** + * Run one query. The seam has already selected this provider and derived `languageId`. + * @param request - the resolved provider query (caller request + derived language id). + * @param signal - optional cancellation; the provider stops its own work when it aborts. + * @returns the normalized, closed-union result. + */ + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} +``` + +```ts type-equiv +/** + * The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query + * execution; exposes exactly the four operations and no protocol escape hatch. + */ +interface LspService { + /** + * Register a provider, atomically reserving its id and every normalized extension. Any conflict + * or invalid input publishes nothing and throws `LspError`; the returned disposer releases all + * reservations. Disposed with the calling fiber. + * @param provider - the backend to register. + * @returns a synchronous disposer releasing the id and all extension reservations. + */ + registerProvider(provider: LspProvider): () => void + /** + * Select a provider by the file's extension and run one query. Selection is per-query and + * order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. + * @param request - the normalized query. + * @param signal - optional cancellation forwarded to the selected provider. + * @returns the normalized, closed-union result. + */ + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} +``` + +`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with stable codes such as `LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, `LSP_UNSUPPORTED_OPERATION`, and `LSP_MALFORMED_RESPONSE`, which callers route on instead of parsing `message`. diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 8650688222..8725625678 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -501,7 +501,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 Agent Note](../../.agents/notes/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 Agent Note](../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md). ## Plugin-contributed log-only events @@ -511,6 +511,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/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index bd4324e697..1fe311fd52 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -49,7 +49,10 @@ interface SubagentStartRequest { * The spawning ("parent") agent — the one whose tool call started this * subagent. REQUIRED: in-process backends read `parent.session.header` for * the working directory, the `parentSession` lineage to stamp on the child, - * and the parent's delegation depth. Out-of-process backends (ACP) ignore it. + * and the parent's delegation depth. The out-of-process backend (ACP) reads + * exactly one field — the session header's cwd, the child's workspace when + * no deployment `cwd` override is configured; nothing else crosses the + * process boundary. */ readonly parent: Agent /** diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 99acf07d24..6e88be4960 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -16,11 +16,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:220`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:242`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:242`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:295`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:257`](../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:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:171`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`tui`](../packages/ui/tui) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:171`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:305`](../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:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | @@ -30,34 +30,34 @@ 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) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:44`](../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:52`](../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), [`goal`](../packages/goal/goal), [`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), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`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), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`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) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../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:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:80`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | -| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | +| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | +| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## Non-harness or undeclared event strings seen in package source | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | [`invariants`](../packages/support/invariants) | +| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`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 d28ba4ad6a..720bcae065 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -139,6 +139,11 @@ flowchart TD subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] end + subgraph group_lsp["packages/lsp"] + pkg_lsp["lsp"] + pkg_lsp_local["lsp-local"] + pkg_tool_lsp["tool-lsp"] + end subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end @@ -162,109 +167,172 @@ flowchart TD pkg_workflow["workflow"] pkg_workflow_workerthread["workflow-workerthread"] end + 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_telemetry --> pkg_brand + pkg_telemetry --> pkg_invariants + pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_invariants pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_timeout 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_web --> pkg_invariants pkg_web --> pkg_llm + pkg_lsp --> pkg_brand + pkg_lsp --> pkg_invariants + pkg_lsp --> 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 + pkg_agent --> pkg_invariants pkg_agent --> pkg_llm pkg_agent --> pkg_scope pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt + pkg_bash --> pkg_invariants pkg_bash --> pkg_sandbox pkg_fs --> pkg_brand + pkg_fs --> pkg_invariants pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox + pkg_compact --> pkg_invariants pkg_compact --> pkg_llm pkg_compact --> pkg_session + pkg_compact_tool_result_prune --> pkg_invariants pkg_compact_tool_result_prune --> pkg_llm pkg_compact_tool_result_prune --> 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_lsp_local --> pkg_brand + pkg_lsp_local --> pkg_invariants + pkg_lsp_local --> pkg_llm + pkg_lsp_local --> pkg_lsp + pkg_lsp_local --> pkg_timeout + pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox + pkg_sandbox_policy --> pkg_invariants 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 pkg_goal --> pkg_agent pkg_goal --> pkg_brand + pkg_goal --> pkg_invariants pkg_goal --> pkg_llm pkg_goal --> pkg_scope pkg_goal --> pkg_session pkg_bash_local --> pkg_bash + pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_timeout 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_basic --> pkg_agent pkg_compact_basic --> pkg_compact pkg_compact_basic --> pkg_compact_tool_result_prune + 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_invariants --> pkg_agent - pkg_invariants --> pkg_llm - pkg_invariants --> pkg_scope - pkg_invariants --> pkg_session pkg_commands --> pkg_agent + pkg_commands --> pkg_invariants pkg_commands --> pkg_scope 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_time_context --> pkg_session 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 @@ -272,24 +340,30 @@ flowchart TD pkg_tools --> pkg_user_approval pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal + pkg_command_goal --> pkg_invariants pkg_goal_session --> pkg_agent pkg_goal_session --> pkg_goal + pkg_goal_session --> pkg_invariants pkg_goal_session --> pkg_llm pkg_goal_session --> pkg_session pkg_bash_sandbox --> pkg_bash pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants pkg_bash_sandbox --> pkg_sandbox pkg_bash_sandbox --> pkg_sandbox_policy pkg_fs_sandbox --> pkg_fs pkg_fs_sandbox --> pkg_fs_local + pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy pkg_permission --> pkg_bash + pkg_permission --> pkg_invariants pkg_permission --> pkg_sandbox pkg_permission --> pkg_sandbox_policy 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 @@ -298,6 +372,7 @@ flowchart TD pkg_agent_loop --> pkg_tools pkg_tool_goal --> pkg_agent pkg_tool_goal --> pkg_goal + pkg_tool_goal --> pkg_invariants pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session pkg_tool_goal --> pkg_system_prompt @@ -305,6 +380,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_sandbox_policy @@ -314,6 +390,7 @@ 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_sandbox pkg_tool_fs --> pkg_sandbox_policy @@ -322,6 +399,7 @@ flowchart TD pkg_tool_fs --> pkg_tools pkg_tool_fs --> pkg_user_approval 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 @@ -329,39 +407,48 @@ 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 @@ -369,6 +456,7 @@ flowchart TD pkg_acp --> pkg_agent pkg_acp --> pkg_bash pkg_acp --> pkg_commands + pkg_acp --> pkg_invariants pkg_acp --> pkg_llm pkg_acp --> pkg_llm_retry pkg_acp --> pkg_permission @@ -380,51 +468,68 @@ 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_tool_lsp --> pkg_invariants + pkg_tool_lsp --> pkg_llm + pkg_tool_lsp --> pkg_lsp + pkg_tool_lsp --> pkg_system_prompt + pkg_tool_lsp --> pkg_timeout + pkg_tool_lsp --> 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 @@ -433,9 +538,12 @@ flowchart TD pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_commands + pkg_tui --> pkg_invariants pkg_tui --> pkg_llm pkg_tui --> pkg_llm_retry pkg_tui --> pkg_session + pkg_tui --> pkg_system_prompt + pkg_tui --> pkg_token_meter pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction pkg_agent_spine_demo --> pkg_agent @@ -446,6 +554,7 @@ flowchart TD pkg_agent_spine_demo --> pkg_invariants pkg_agent_spine_demo --> pkg_llm pkg_agent_spine_demo --> pkg_llm_retry + 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 @@ -458,6 +567,7 @@ flowchart TD pkg_agent_spine_demo --> pkg_tools pkg_agent_spine_demo --> pkg_workspace_context pkg_tool_ralph --> pkg_agent + pkg_tool_ralph --> pkg_invariants pkg_tool_ralph --> pkg_llm pkg_tool_ralph --> pkg_subagent pkg_tool_ralph --> pkg_system_prompt @@ -465,15 +575,18 @@ flowchart TD pkg_tool_ralph --> pkg_workflow 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 @@ -481,6 +594,7 @@ flowchart TD pkg_acp_demo --> pkg_app_boot pkg_acp_demo --> pkg_command_goal pkg_acp_demo --> pkg_commands + pkg_acp_demo --> pkg_invariants pkg_acp_demo --> pkg_session_persistence_jsonl pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_user_interaction @@ -488,6 +602,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 @@ -499,6 +614,7 @@ flowchart TD pkg_tui_demo --> pkg_app_boot pkg_tui_demo --> pkg_command_goal pkg_tui_demo --> pkg_commands + pkg_tui_demo --> pkg_invariants pkg_tui_demo --> pkg_llm pkg_tui_demo --> pkg_session pkg_tui_demo --> pkg_session_persistence_jsonl @@ -511,102 +627,105 @@ 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` | — | -| [`scope`](../packages/core/scope) | `core` | — | -| [`skill`](../packages/skill/skill) | `skill` | — | -| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | -| [`acp-snapshot`](../packages/support/acp-snapshot) | `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) | -| [`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) | -| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`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) | -| [`bash`](../packages/bash/bash) | `bash` | [`sandbox`](../packages/sandbox/sandbox) | -| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `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) | -| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`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) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | -| [`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-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`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) | -| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | -| [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`scope`](../packages/core/scope) | -| [`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) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal) | -| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`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), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| [`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), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`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) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`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), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`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-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`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) | -| [`tui-demo`](../packages/examples/tui-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), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`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) | +| [`invariants`](../packages/support/invariants) | `support` | — | +| [`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) | +| [`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) | +| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | +| [`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` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`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` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox) | +| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `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) | +| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | +| [`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), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | +| [`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) | +| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | +| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`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) | +| [`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), [`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) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`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), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`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), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| [`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), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`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) | +| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`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) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`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), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`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-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`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), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`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) | +| [`tui-demo`](../packages/examples/tui-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), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`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/tool-catalog.md b/docs/tool-catalog.md index 6196a71521..8273203616 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `context/message goal snapshot for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | +| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | @@ -487,6 +488,52 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/ create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. +## `@deepseek-ai/dsh-tool-lsp` + +### `lsp` + +Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration. + +```json +{ + "type": "object", + "properties": { + "operation": { + "type": "string", + "description": "goToDefinition, findReferences, goToImplementation, or hover.", + "enum": [ + "goToDefinition", + "findReferences", + "goToImplementation", + "hover" + ] + }, + "file_path": { + "type": "string", + "description": "The source file to query, relative to the workspace or absolute." + }, + "line": { + "type": "number", + "description": "One-based line of the cursor." + }, + "character": { + "type": "number", + "description": "One-based UTF-16 column of the cursor." + } + }, + "required": [ + "operation", + "file_path", + "line", + "character" + ] +} +``` + +Source: [`packages/lsp/tool-lsp/src/index.ts`](../packages/lsp/tool-lsp/src/index.ts) + +The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. + ## `@deepseek-ai/dsh-tool-ralph` ### `ralph` diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index e9cfdb6687..1503d47c0f 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 AND the filesystem tools to the # workspace and asks before a wider retry. Snapshot runs select @@ -58,20 +63,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/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 3460c3c9af..775d0ec053 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -33,6 +33,7 @@ const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.m const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) +const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { switch (value) { @@ -70,6 +71,7 @@ const SCENARIOS: Scenario[] = [ { name: 'fs-terminal-card', hasModelTurn: true, recorded: true }, { name: 'todo-plan', hasModelTurn: true, recorded: true }, { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, + { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG }, { name: 'workspace-edit', hasModelTurn: true, recorded: true }, { name: 'fs-read', hasModelTurn: true, recorded: true }, { name: 'fs-write', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml new file mode 100644 index 0000000000..3bd5f5393c --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml @@ -0,0 +1,41 @@ +# Test-only composition: the ACP subagent backend on the real Loader/app path. +# The scripted model delegates once; the scripted mock ACP child (MOCK_ECHO_CWD) +# echoes its process cwd and announced session cwd, so parent-session cwd +# inheritance is asserted keylessly end to end. `cwd` is deliberately omitted — +# the inheritance branch under test. The child command path is machine-absolute, +# so the driving e2e supplies it via DSH_TEST_MOCK_ACP_SERVER. +- id: mock-llm + name: './mock-delegating-llm.ts' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-acp + name: '@deepseek-ai/dsh-subagent-acp' + config: + providerName: acp + command: !!js process.execPath + args: + - !!js process.env.DSH_TEST_MOCK_ACP_SERVER + permission: reject + env: + MOCK_ECHO_CWD: '1' + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: acp + toolName: subagent + # ACP advertises no depthLimit: the child harness owns its own recursion + # budget, so the local numeric default cannot apply here. + maxDepth: 'provider-managed' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: mock + model: mock-delegate + persona: 'Test ACP subagent cwd inheritance.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts new file mode 100644 index 0000000000..d146b8df80 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +/** Test driver: one delegation turn through a headless Loader composition. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('acp-subagent cwd driver requires a config path') + +const ctx = await boot('acp-subagent-cwd-e2e', resolveConfigPath(configPath, undefined)) +try { + await runOneShot(ctx, { task: 'delegate' }) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts new file mode 100644 index 0000000000..9d3857ffc8 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts @@ -0,0 +1,48 @@ +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' + +/** + * Test adapter for the `mock-delegate` model: the first request calls the + * `subagent` tool once, and the follow-up streams the tool result text back + * verbatim — so the ACP child's answer (the scripted mock server's cwd echo) + * reaches the REPL stdout for the driving e2e to assert. + */ +class MockDelegatingAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const toolResultText = options.messages.at(-1)?.content + .filter(block => block.type === 'tool-result') + .flatMap(block => block.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') ?? '' + + if (toolResultText.length === 0) { + const args = JSON.stringify({ description: 'cwd probe', prompt: 'report your workspace' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: CallId('call-delegate'), name: 'subagent', argumentsDelta: args } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-delegate'), name: 'subagent', arguments: args } } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + + const reply = `child reported:\n${toolResultText}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: reply.length } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'mock-llm' +export const inject = ['llm'] + +/** + * Register the delegating mock adapter under the `mock` provider. + * @param ctx - the plugin context supplying `ctx.llm`. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['mock'], new MockDelegatingAdapter()) +} diff --git a/examples/acp-agent/tests/lsp.cordis.snapshot.yml b/examples/acp-agent/tests/lsp.cordis.snapshot.yml new file mode 100644 index 0000000000..dc672376b5 --- /dev/null +++ b/examples/acp-agent/tests/lsp.cordis.snapshot.yml @@ -0,0 +1,29 @@ +# Keyless replay keeps the LSP composition intact and replaces only the model adapter. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: lsp + name: '@deepseek-ai/dsh-lsp' + - id: lsp-local + name: '@deepseek-ai/dsh-lsp-local' + config: + servers: + fixture: + command: !!js process.execPath + args: ['./lsp-server.mjs'] + extensionToLanguage: + '.ts': typescript + - id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + - id: tool-lsp + name: '@deepseek-ai/dsh-tool-lsp' + config: + maxLocations: 1 + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/tests/lsp.cordis.yml b/examples/acp-agent/tests/lsp.cordis.yml new file mode 100644 index 0000000000..49c9099d65 --- /dev/null +++ b/examples/acp-agent/tests/lsp.cordis.yml @@ -0,0 +1,25 @@ +# Exercise the model-facing LSP tool through the shipped ACP app and Loader entry path. +# The scenario workspace supplies the deterministic stdio server used by this test composition. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../cordis.yml + patches: + - insert: + - id: lsp + name: '@deepseek-ai/dsh-lsp' + - id: lsp-local + name: '@deepseek-ai/dsh-lsp-local' + config: + servers: + fixture: + command: !!js process.execPath + args: ['./lsp-server.mjs'] + extensionToLanguage: + '.ts': typescript + - id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + - id: tool-lsp + name: '@deepseek-ai/dsh-tool-lsp' + config: + maxLocations: 1 diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index 65f92c4916..7db76466bc 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-2ef0a5f14624/b5e2b8c5e6a6-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-766ddb6deb99/b7b4bdfcf7aa-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 354a014a63..8ae92a7be9 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -131,8 +131,8 @@ {"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"e2d45b4a-ff48-488d-aeb6-8edc9dc5c3de","outcome":"allowed-once"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"69daf6e5-d7a8-442c-a624-9172517cba58","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"69daf6e5-d7a8-442c-a624-9172517cba58","outcome":"allowed-once"}} {"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index ff6d1187b3..2a612ed289 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -155,8 +155,8 @@ {"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"46c8dba4-52c9-4a6a-b6a6-5f34c95c28df","outcome":"rejected"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"78869d2f-1a83-4672-8a66-2a75d1ffe49f","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"78869d2f-1a83-4672-8a66-2a75d1ffe49f","outcome":"rejected"}} {"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index 54601f5354..6c8261e7cb 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -89,8 +89,8 @@ {"type":"assistant/chunk","seq":87,"time":1784045703776,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":88,"time":1784045703780,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"surfaceOp":"append"} {"type":"tool/call","seq":89,"time":1784045703780,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} -{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} -{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"c37500b3-c252-4a9b-ad0d-9c4349419b30","outcome":"allowed-once"}} +{"type":"approval/asked","seq":90,"time":1784045703782,"data":{"id":"50a67945-0145-4850-93bf-3552f2550b47","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} +{"type":"approval/decided","seq":91,"time":1784045703786,"data":{"id":"50a67945-0145-4850-93bf-3552f2550b47","outcome":"allowed-once"}} {"type":"tool/result","seq":92,"time":1784045703798,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[89],"surfaceOp":"append"} {"type":"step/end","seq":93,"time":1784045703798,"data":{"turn":1,"step":1}} {"type":"step/start","seq":94,"time":1784045703799,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 247e13a075..1517df75e9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,8 +55,8 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"68f1e09d-f3e5-4e39-8a51-da082ba3ba99","outcome":"rejected"}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"0ff46a23-8e38-46cd-9b1a-99bf363032a4","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"0ff46a23-8e38-46cd-9b1a-99bf363032a4","outcome":"rejected"}} {"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/input.json b/examples/acp-agent/tests/snapshots/lsp-definition/input.json new file mode 100644 index 0000000000..2b49f7d280 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl new file mode 100644 index 0000000000..00780e7787 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl @@ -0,0 +1,23 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the lsp tool exactly once to find the definition at subject.ts line 1 character 7, then reply with exactly DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_lsp_definition","name":"lsp","argumentsDelta":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"subject.ts\",\"line\":1,\"character\":7}"}} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_lsp_definition","content":[{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl new file mode 100644 index 0000000000..ce84702157 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl @@ -0,0 +1,7 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_lsp_definition","title":"LSP goToDefinition subject.ts:1:7","kind":"search","status":"in_progress","locations":[{"path":"subject.ts","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_lsp_definition","status":"completed","content":[{"type":"content","content":{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md new file mode 100644 index 0000000000..8e49c2dce5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md @@ -0,0 +1,27 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json new file mode 100644 index 0000000000..4fa5010b72 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -0,0 +1,506 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "lsp", + "description": "Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.", + "parameters": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "description": "goToDefinition, findReferences, goToImplementation, or hover.", + "enum": [ + "goToDefinition", + "findReferences", + "goToImplementation", + "hover" + ] + }, + "file_path": { + "type": "string", + "description": "The source file to query, relative to the workspace or absolute." + }, + "line": { + "type": "number", + "description": "One-based line of the cursor." + }, + "character": { + "type": "number", + "description": "One-based UTF-16 column of the cursor." + } + }, + "required": [ + "operation", + "file_path", + "line", + "character" + ] + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})." + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/workspace/lsp-server.mjs b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/lsp-server.mjs new file mode 100644 index 0000000000..431b322e0b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/lsp-server.mjs @@ -0,0 +1,57 @@ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +let buffered = Buffer.alloc(0) + +function frame(message) { + const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message })) + return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]) +} + +function location(line) { + return { + uri: pathToFileURL(resolve('subject.ts')).href, + range: { start: { line, character: 6 }, end: { line, character: 12 } }, + } +} + +function handle(message) { + switch (message.method) { + case 'initialize': + process.stdout.write(frame({ + id: message.id, + result: { + capabilities: { + positionEncoding: 'utf-16', + textDocumentSync: 1, + definitionProvider: true, + }, + }, + })) + break + case 'textDocument/definition': + process.stdout.write(frame({ id: message.id, result: [location(0), location(1)] })) + break + case 'shutdown': + process.stdout.write(frame({ id: message.id, result: null })) + break + case 'exit': + process.exit(0) + } +} + +process.stdin.on('data', (chunk) => { + buffered = Buffer.concat([buffered, chunk]) + for (;;) { + const headerEnd = buffered.indexOf('\r\n\r\n') + if (headerEnd < 0) return + const match = /Content-Length: (\d+)/i.exec(buffered.toString('ascii', 0, headerEnd)) + if (match === null) throw new Error('missing Content-Length') + const length = Number(match[1]) + const bodyStart = headerEnd + 4 + if (buffered.length < bodyStart + length) return + const message = JSON.parse(buffered.toString('utf8', bodyStart, bodyStart + length)) + buffered = buffered.subarray(bodyStart + length) + handle(message) + } +}) diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts new file mode 100644 index 0000000000..6f3d62ca43 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts @@ -0,0 +1,2 @@ +export const answer = 42 +console.log(answer) diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index a812f71445..3cbcb0e571 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -20,6 +20,8 @@ flowchart LR cfg --> plugin_cordis_web plugin_cordis_web_fetch_local["web-fetch-local
@deepseek-ai/dsh-web-fetch-local"] cfg --> plugin_cordis_web_fetch_local + plugin_cordis_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_cordis_token_meter plugin_cordis_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] cfg --> plugin_cordis_tui_agent plugin_cordis_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] @@ -41,6 +43,7 @@ flowchart LR | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | | `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 39b6cd3b48..01dcfc50be 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -44,6 +44,9 @@ - id: web-fetch-local name: '@deepseek-ai/dsh-web-fetch-local' +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + # The app bundle pre-creates the self-referential demo's `main` agent. - id: tui-agent name: '@deepseek-ai/dsh-tui-demo' diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 3e5876c2b6..e467d32833 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"] @@ -54,6 +56,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 aa2d9fd7ca..05c4facea9 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' @@ -35,13 +37,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/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index d992fc9efa..fcebddb863 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-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/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index ca1c59871e..6cfa31a750 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-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 ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' @@ -46,8 +45,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 { @@ -56,14 +55,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(ToolResultPruneService) await ctx.plugin(BasicCompactService, options.compact) } 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/package.json b/examples/package.json index 8207c88ea3..ec35ac39e6 100644 --- a/examples/package.json +++ b/examples/package.json @@ -27,6 +27,8 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", + "@deepseek-ai/dsh-lsp": "workspace:*", + "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", @@ -36,6 +38,7 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-tui-demo": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-subagent-acp": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", @@ -45,6 +48,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", + "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 8cd95e896b..f38d533813 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -10,7 +10,7 @@ pnpm run demo:tui The command needs `DEEPSEEK_API_KEY` in the environment or gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent runs; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. Run `pnpm run demo:code-mode tui` for the Code Mode overlay. diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml index 45f5a7af04..a11e86a068 100644 --- a/examples/tui-agent/code-mode.cordis.yml +++ b/examples/tui-agent/code-mode.cordis.yml @@ -19,7 +19,7 @@ welcome: 'TUI Code Mode ready. Give it a multi-tool task.' ui: showReasoning: true - maxToolOutputLines: 12 + maxToolOutputLines: 6 persona: | You are a coding agent powered by the {{model}} model. diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 7426416204..88d52873f2 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -31,7 +31,7 @@ welcome: 'TUI agent ready. Give it a coding task.' ui: showReasoning: true - maxToolOutputLines: 12 + maxToolOutputLines: 6 persona: | You are a coding agent powered by the {{model}} model. diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index c55b3d355c..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, 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' @@ -18,7 +18,21 @@ function textChunks(text: string): StreamChunk[] { /** Keyless two-step adapter for the real-PTY TUI conversation test. */ class ScriptedTuiAdapter extends LlmAdapter { - async * stream(options: GenerateOptions): AsyncIterable { + override listModels(provider: string): Promise { + return Promise.resolve([ + { provider, id: 'tui-scripted-model', name: 'Scripted Base' }, + { provider, id: 'tui-scripted-model-pro', name: 'Scripted Pro' }, + ]) + } + + 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') + } const hasToolResult = options.messages.at(-1)?.content.some(block => block.type === 'tool-result') ?? false if (hasToolResult) { for (const chunk of textChunks(FINAL_TEXT)) yield chunk diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index f8de00a1a6..dbe22cb53b 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -12,6 +12,9 @@ config: cwd: !!js process.cwd() +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + - id: tui-agent name: '@deepseek-ai/dsh-tui-demo' config: @@ -21,5 +24,6 @@ workspaceContext: maxBytes: 65536 welcome: 'scripted TUI ready.' + persona: 'Scripted model {{model}}.' ui: showReasoning: true diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt index 29ea17fd66..8aadb202ff 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt @@ -67,7 +67,7 @@ buffer style 1-1 inverse 28| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -29| "/workspace/project ↑3.0k ↓115 idle reasoning:on tools:compact" - style 0-58 dim - style 67-99 dim +29| "/tmp/dsh-tui-snapshot-bash-te ↑3.0k ↓115 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim 30-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt index 2850e0c7d0..ec3b873efd 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -73,7 +73,7 @@ buffer style 1-1 inverse 30| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -31| "/workspace/project ↑3.1k ↓158 idle reasoning:on tools:compact" - style 0-49 dim - style 67-99 dim +31| "/tmp/dsh-tui-snapshot-code-mo ↑3.1k ↓158 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim 32-35| diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index e4e6d0a040..02fbde0fe0 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -111,6 +111,6 @@ buffer style 1-1 inverse 48| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -49| "/workspace/project ↑18 ↓18 idle reasoning:on tools:compact" - style 0-61 dim - style 67-99 dim +49| "/tmp/dsh-tui-snapshot-cordis-dyn ↑18 ↓18 7% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-31 dim + style 42-99 dim diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt index 459fddd90c..465ceee4e1 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt @@ -101,6 +101,6 @@ buffer style 1-1 inverse 45| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -46| "/workspace/project ↑3.5k ↓227 idle reasoning:on tools:compact" - style 0-56 dim - style 67-99 dim +46| "/tmp/dsh-tui-snapshot-dynamic ↑3.5k ↓227 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt index d56db72ccb..47eb6889fd 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt @@ -64,7 +64,7 @@ buffer style 1-1 inverse 29| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -30| "/workspace/project ↑2.9k ↓41 idle reasoning:on tools:compact" - style 0-62 dim - style 67-99 dim +30| "/tmp/dsh-tui-snapshot-multi-tu ↑2.9k ↓41 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-29 dim + style 42-99 dim 31-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt index f4c3d83b3d..3af0096d1f 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt @@ -86,6 +86,6 @@ buffer style 1-1 inverse 37| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -38| "/workspace/project ↑20 ↓6 idle reasoning:on tools:compact" - style 0-55 dim - style 67-99 dim +38| "/tmp/dsh-tui-snapshot-parallel-fi ↑20 ↓6 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-32 dim + style 42-99 dim diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt index 527e6e4016..9ba20c4a28 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt @@ -76,6 +76,6 @@ buffer style 1-1 inverse 34| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -35| "/workspace/project ↑3.1k ↓145 idle reasoning:on tools:compact" - style 0-49 dim - style 67-99 dim +35| "/tmp/dsh-tui-snapshot-todo-pl ↑3.1k ↓145 3% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-28 dim + style 42-99 dim diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index e2fa7377c6..d8ecb2a978 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -24,7 +24,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => { + it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await runTuiPtySmoke({ label: 'tui-agent conversation', tempDirPrefix: 'tui-agent-conversation-', @@ -32,7 +32,9 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { configPath: scriptedConfigPath, tsconfigPath, actions: [ - { waitFor: 'scripted TUI ready.', send: 'exercise the TUI\r' }, + { waitFor: 'scripted TUI ready.', send: '/model\r' }, + { waitFor: 'Select model', send: '\x1b[B\r' }, + { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, { waitFor: 'How should the scripted run proceed?', send: '\r' }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index a9f937f883..1ac3f5b55e 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -15,6 +15,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -32,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' @@ -170,6 +171,7 @@ async function mountScenarioContext( tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' }, skills: { local: { agentsHome: join(cwd, '.agents') } }, }) + await ctx.plugin(TokenMeterService) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) diff --git a/knip.json b/knip.json index 845d38a95d..b6a89c8eba 100644 --- a/knip.json +++ b/knip.json @@ -13,7 +13,10 @@ "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", + "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", + "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", + "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" ], @@ -42,28 +45,29 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/lsp/lsp-local": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/fixture-server.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"], + "ignoreDependencies": ["typescript-language-server"] + }, "packages/sandbox/sandbox-local": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "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"], @@ -71,8 +75,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"], @@ -108,8 +111,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"], @@ -177,8 +179,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 5c831f6497..5f1441c115 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", + "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", @@ -77,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-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/AGENTS.md b/packages/AGENTS.md index 400436a9f1..77a4712e04 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -15,7 +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. +- **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/README.md b/packages/README.md index c491ab2cc3..eebcfbde84 100644 --- a/packages/README.md +++ b/packages/README.md @@ -15,6 +15,7 @@ Packages live at `packages///`; groups are containers, while names r | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface | | [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface | | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, the model-facing file tools, and the bash-backed discovery tools | Product — stable surface | +| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface | | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | | [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface | 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..3cc7bd62e2 --- /dev/null +++ b/packages/bash/bash-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 d4ac641f8d..9faf613c71 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", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -31,10 +37,11 @@ "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:^", "@deepseek-ai/dsh-sandbox-policy": "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..b79b626033 --- /dev/null +++ b/packages/bash/bash-sandbox/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 531ae140ea..fcd79e0296 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 5fab273e39..93497df719 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/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-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "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..f54f280f5d --- /dev/null +++ b/packages/bash/bash/src/invariant.ts @@ -0,0 +1,22 @@ +/** Package-owned invariant companion for the bash seam. @module @deepseek-ai/dsh-bash/invariant */ + +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this stateless seam owns request/result types, while executors and policy own observations. */ +const install: InvariantInstaller = () => {} + +/** + * Register the bash 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/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index de6feb55b9..51175e6080 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../sandbox/sandbox" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 1ce1103e48..5ef96794af 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" @@ -25,9 +30,10 @@ "@deepseek-ai/dsh-agent": "^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-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", @@ -42,10 +48,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-sandbox-policy": "workspace:^", diff --git a/packages/bash/tool-bash/src/invariant.ts b/packages/bash/tool-bash/src/invariant.ts new file mode 100644 index 0000000000..0620f0cfa9 --- /dev/null +++ b/packages/bash/tool-bash/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 c17b10ab8d..a3145c2f52 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../../sandbox/sandbox" }, + { + "path": "../../support/invariants" + }, { "path": "../../sandbox/sandbox-policy" } 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..3455104441 --- /dev/null +++ b/packages/code-runtime/code-runtime-worker/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..9c4019699b --- /dev/null +++ b/packages/code-runtime/code-runtime/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 7811ef0531..56a32930c9 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -84,4 +84,5 @@ describe('CodeRuntime service seam', () => { const { ctx } = await setup() await expect(ctx.plugin(StubRuntime)).rejects.toThrow(/registered/) }) + }) 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/README.md b/packages/compact/compact-basic/README.md index d789bf2328..66442d2fd1 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-compact-basic -The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call (interceptable at `llm/stream`). +The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call that replays the conversation prefix to reuse the provider's KV cache (interceptable at `llm/stream`). This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design. @@ -9,32 +9,39 @@ 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. - **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune. - **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. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain 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. +- **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 call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. 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, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. +- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific 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 summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress. 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 @@ -53,6 +60,20 @@ export function apply(ctx: Context): void { Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. 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 @@ -75,30 +96,16 @@ Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces t Replacing rather than append-only. Each checkpoint invalidates reuse from the first replaced history token; the unchanged request prefix before that range remains reusable. -### Auxiliary summarizer user message +### Auxiliary summarizer request #### What the model sees -The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored. +The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored. -#### Token effect - -This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once. - -#### KV Cache effect - -Independent of the conversation request cache. An auxiliary call can reuse an exact transcript prefix, while a different selected range or rendering invalidates reuse from its first changed token. - -### Auxiliary summarizer system prompt - -#### What the model sees - -The summarization model receives the checkpoint-writing instruction below. - -##### Auxiliary summarizer system prompt +##### Compaction instruction (final user message) ```markdown -You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context. +You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context. Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section. @@ -129,17 +136,18 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t Rules: - Preserve exact file paths, commands, error strings, identifiers, and function signatures. - Capture user feedback and explicit instructions faithfully, especially corrections. -- Do NOT mention this summarization process or that the context was compacted. -- If the transcript already contains a block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure. +- Do NOT mention this summarization request or that the context was compacted. +- Output only the checkpoint text: do not call any tool or take any other action. +- If the conversation already contains a block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure. ``` #### Token effect -Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt. +This is a separate model call: the replayed conversation prefix plus the fixed instruction as input, with `maxTokens`-capped output. Convergence retries can pay this cost more than once. #### KV Cache effect -Prefix-stable for auxiliary calls while this instruction and the summarizer route are unchanged. Changing either starts a different prefix; transcript changes occur after the instruction. +The replayed system prompt, tools, and shadowed-region messages match the conversation's last routed request byte-for-byte, so the provider's warm prefix cache is reused up to the trailing instruction; only that instruction, and the summary output, is uncached. Routing the summarizer to a different provider/model, or compacting a non-head range, forgoes this reuse. ## Known Limitations and Deferred Work diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index a8844b1820..6a93e249a3 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/config.ts b/packages/compact/compact-basic/src/config.ts index 1587fac272..b954537af8 100644 --- a/packages/compact/compact-basic/src/config.ts +++ b/packages/compact/compact-basic/src/config.ts @@ -1,111 +1,310 @@ /** - * 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) + } + + 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 (model !== undefined && typeof model !== 'string') { + throw new Error(`${name}.summarizationModel must be a string`) + } + 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', + ) } } -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 5fc02964b8..10c64d10ca 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -10,29 +10,79 @@ 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' // Type-only: makes the optional sibling service available to `ctx.get()`. import type {} from '@deepseek-ai/dsh-compact-tool-result-prune' -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 { SummarizationInput } 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. @@ -45,22 +95,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() } @@ -90,16 +144,33 @@ 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, failure, priorFailures, signal, next) => { - const priorOverflowFailures = priorFailures.filter(item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE).length - if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE - || priorOverflowFailures >= this.config.maxOverflowRetries - || signal.aborted) return next() + ctx.on('agent/request-error', async ( + agent, + _turn, + _step, + _error, + failure, + priorFailures, + signal, + next, + ) => { + const priorOverflowFailures = priorFailures.filter( + item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE, + ).length + if (failure.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 (priorOverflowFailures >= policy.maxOverflowRetries) return next() const generation = agent.session.surface.replaceGeneration let result: CompactionResult | null @@ -135,19 +206,25 @@ export class BasicCompactService extends CompactService { } /** - * Summarize a rendered region through a direct one-shot `ctx.llm.stream()` - * call. Override this sole hook for a template or remote summarizer. - * @param text - plain-text conversation region to condense. + * Summarize the replayed conversation region through a direct one-shot + * `ctx.llm.stream()` call whose prefix reuses the conversation's own system + * prompt, tools, and messages so the provider's KV cache is not invalidated. + * Override this sole hook for a template or remote summarizer. + * @param input - replayed conversation prefix (system, tools, and leading messages) to condense. * @param agent - supplies routed-model history, fallback model, and session id. * @param signal - optional cancellation forwarded to the adapter. * @returns safe text summary blocks and exact auxiliary-call provenance. */ protected async summarize( - text: string, + input: SummarizationInput, 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, input, agent, signal) } /** @@ -165,16 +242,15 @@ 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 - const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio) let measurement = meter.measure(agent.session) switch (trigger) { case 'context-overflow': break case 'pressure': - if (measurement.totalTokens < threshold) return null break /* v8 ignore next -- closed-union exhaustiveness guard */ default: @@ -182,25 +258,43 @@ export class BasicCompactService extends CompactService { } // Pruning is optional so compact-basic remains independently composable. - // Once either trigger qualifies, land the model-free pass before choosing - // a summary range, then remeasure through the singleton replay fold. + // Overflow always qualifies; pressure first resolves the routed model's + // capacity and checks its target-specific threshold. const prune = this.ctx.get('toolResultPrune') - if (prune !== undefined) { - prune.pruneSession(agent.session) - measurement = meter.measure(agent.session) - } if (trigger === 'context-overflow') { + if (prune !== undefined) { + prune.pruneSession(agent.session) + measurement = meter.measure(agent.session) + } const range = selectCompactableRange(agent.session, measurement, 0) if (range === null) return null return this.compactRegion(range.start, range.end, agent, signal) } - if (measurement.totalTokens < threshold) return null + 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) + if (measurement.totalTokens < spec.thresholdTokens) return null + + // Once pressure qualifies, land the model-free pass before choosing a + // summary range, then remeasure through the singleton replay fold. + if (prune !== undefined) { + prune.pruneSession(agent.session) + measurement = meter.measure(agent.session) + } + 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 @@ -209,12 +303,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})`, ) } @@ -236,7 +330,7 @@ export class BasicCompactService extends CompactService { const session = agent.session return compactSurfaceRegion({ meter: this.ctx.tokenMeter, - summarize: (text, owner, abort) => this.summarize(text, owner, abort), + summarize: (input, owner, abort) => this.summarize(input, owner, abort), }, session, start, end, agent, signal) } } diff --git a/packages/compact/compact-basic/src/invariant.ts b/packages/compact/compact-basic/src/invariant.ts new file mode 100644 index 0000000000..172790d233 --- /dev/null +++ b/packages/compact/compact-basic/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/src/region.ts b/packages/compact/compact-basic/src/region.ts index ac1ee260b1..86ae804e82 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -5,20 +5,20 @@ */ import { - renderTranscript, toolPairingBalancedAfter, toolPairingBalancedBefore, } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import type { Message } from '@deepseek-ai/dsh-llm' import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import { frameSummary } from './summarizer.ts' -import type { SummaryResult } from './summarizer.ts' +import type { SummarizationInput, SummaryResult } from './summarizer.ts' interface RegionDependencies { readonly meter: TokenMeterService - summarize(text: string, agent: Agent, signal?: AbortSignal): Promise + summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise } /** @@ -122,8 +122,8 @@ export async function compactSurfaceRegion( throw new Error('compaction: selected surface changed before summarization began') } const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0) - const text = renderTranscript(session.events, shadowedSeqs) - const { summary, provider, model, maxTokens } = await dependencies.summarize(text, agent, signal) + const summarizationInput = buildSummarizationInput(session, shadowedSeqs) + const { summary, provider, model, maxTokens } = await dependencies.summarize(summarizationInput, agent, signal) const currentMeasurement = dependencies.meter.measure(session) if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) { @@ -173,6 +173,34 @@ export async function compactSurfaceRegion( } } +/** + * Reconstruct the last routed request's cacheable prefix for the shadowed + * region: its system prompt and tool schemas, then the request-only message + * prefix followed by the region's own derived messages in surface order. The + * summarizer appends only the compaction instruction after this, so the call + * is a genuine prefix of the conversation and reuses the provider's KV cache. + * @param session - session supplying the request header and per-node projection. + * @param shadowedSeqs - the surface-node seqs, in order, being compacted. + * @returns the replayed conversation prefix to condense. + */ +function buildSummarizationInput( + session: Session, + shadowedSeqs: readonly number[], +): SummarizationInput { + const header = session.requestHeader() + const events = session.events + const regionMessages = shadowedSeqs + // shadowedSeqs are current surface seqs, so each is a valid log index. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .map(seq => session.deriveEventMessage(events[seq]!)) + .filter((message): message is Message => message !== null) + return { + ...header?.system === undefined ? {} : { system: header.system }, + ...header?.tools === undefined ? {} : { tools: header.tools }, + messages: [...header?.messagePrefix ?? [], ...regionMessages], + } +} + /** Inspect the current turn boundary and latest compaction bracket once. */ function inspectTurnTail( events: readonly SessionEvent[], diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 32e308bce4..cf34c2ad91 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -6,17 +6,28 @@ import type { Context } from 'cordis' import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, FinishReason, GenerateOptions, Message, ToolSchema } 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 = '' const SUMMARY_CLOSE_TAG = '' -/** Fixed structure required from the auxiliary summarization call. */ -const SUMMARIZE_SYSTEM_PROMPT = [ - 'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.', +/** + * The summarization directive, delivered as the FINAL user message after the + * replayed conversation rather than as a distinct summarizer system prompt. + * Keeping the conversation's own system prompt, tools, and message prefix in + * front of it makes the auxiliary call a genuine prefix of the last routed + * request, so the provider's KV cache is reused instead of invalidated. + */ +const COMPACTION_INSTRUCTION = [ + 'You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context.', '', 'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.', '', @@ -47,14 +58,30 @@ const SUMMARIZE_SYSTEM_PROMPT = [ 'Rules:', '- Preserve exact file paths, commands, error strings, identifiers, and function signatures.', '- Capture user feedback and explicit instructions faithfully, especially corrections.', - '- Do NOT mention this summarization process or that the context was compacted.', - `- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`, + '- Do NOT mention this summarization request or that the context was compacted.', + '- Output only the checkpoint text: do not call any tool or take any other action.', + `- If the conversation already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`, ].join('\n') /** Framing that makes the replacement user message established context. */ const CHECKPOINT_PREAMBLE = 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.' +/** + * The replayed conversation surface the summarizer condenses. Reproducing the + * last routed request's system prompt, tools, and leading messages verbatim + * lets the auxiliary call reuse the provider's warm prefix cache; the trailing + * compaction instruction is then the only novel input. + */ +export interface SummarizationInput { + /** The conversation's own system prompt, reused for prefix-cache alignment; absent for a system-less request. */ + readonly system?: string + /** The conversation's tool schemas, reused for prefix-cache alignment; absent when the request carried none. */ + readonly tools?: readonly ToolSchema[] + /** The request prefix followed by the shadowed region, in surface order, that precedes the compaction instruction. */ + readonly messages: readonly Message[] +} + /** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */ export interface SummaryResult { summary: ContentBlock[] @@ -64,18 +91,20 @@ export interface SummaryResult { } /** - * Run the default direct `ctx.llm.stream()` summarization call. + * Run the default cache-reusing `ctx.llm.stream()` summarization call: replay + * the conversation prefix, then append the compaction instruction as the final + * user message so the provider's warm prefix cache is reused. * @param ctx - context providing the LLM service. * @param config - resolved backend configuration. - * @param text - rendered transcript region to summarize. + * @param input - replayed conversation prefix (system, tools, and leading messages) to condense. * @param agent - supplies routed-model history, fallback model, and session id. * @param signal - optional cancellation forwarded to the adapter. * @returns safe text-only summary blocks and exact call provenance. */ export async function summarizeWithLlm( ctx: Context, - config: ResolvedConfig, - text: string, + config: SummaryConfig, + input: SummarizationInput, agent: Agent, signal?: AbortSignal, ): Promise { @@ -97,14 +126,16 @@ export async function summarizeWithLlm( } const assembler = new BlockAssembler() + const messages: Message[] = [ + ...input.messages, + { role: 'user', content: [{ type: 'text', text: COMPACTION_INSTRUCTION }] }, + ] const options: GenerateOptions = { provider: target.provider, model: target.model, - messages: [{ - role: 'user', - content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }], - }], - system: SUMMARIZE_SYSTEM_PROMPT, + messages, + ...input.system === undefined ? {} : { system: input.system }, + ...input.tools === undefined ? {} : { tools: [...input.tools] }, maxTokens: config.maxTokens, sessionId: agent.session.id, ...signal === undefined ? {} : { signal }, 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 d0340e8b0d..9e9c8437db 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -3,22 +3,65 @@ import { Context } from 'cordis' 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 type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.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, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { + ContentBlock, + GenerateOptions, + LlmFailure, + LlmModelContext, + Message, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' -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 } @@ -26,6 +69,21 @@ function agent(session: Session, model?: string): Agent { return { session, options: model === undefined ? {} : { provider: model, model } } as Agent } +/** Flatten every text fragment the summarizer received, recursing tool-result blocks. */ +function summarizedText(input: SummarizationInput): string { + const collect = (blocks: readonly ContentBlock[]): string => + blocks.map(block => + block.type === 'text' ? block.text + : block.type === 'tool-result' ? collect(block.content) + : '').join('\n') + return input.messages.map(message => collect(message.content)).join('\n') +} + +/** A minimal replayed prefix carrying one user message of the given text. */ +function promptInput(text: string): SummarizationInput { + return { messages: [{ role: 'user', content: [{ type: 'text', text }] }] } +} + /** Closed two-message turns followed by one open turn for durable compaction events. */ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session { const session = new Session(SessionId(`conversation-${turns}`)) @@ -141,14 +199,14 @@ class TestCompactService extends BasicCompactService { summaryModel = 'summary-model' error: unknown mutateDuringSummary: (() => void) | undefined - calls: Array<{ text: string; signal: AbortSignal | undefined }> = [] + calls: Array<{ input: SummarizationInput; signal: AbortSignal | undefined }> = [] override async summarize( - text: string, + input: SummarizationInput, _agent: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { - this.calls.push({ text, signal }) + this.calls.push({ input, signal }) this.mutateDuringSummary?.() if (this.error !== undefined) throw this.error return { @@ -178,43 +236,131 @@ 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('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 ctx = createContext() const bad = [ [{ maxTokens: 0 }, /maxTokens/], [{ compactionRetries: -1 }, /compactionRetries/], @@ -222,20 +368,62 @@ 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 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\)/], + [{ 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/], + [{ + 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 }] }, + /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/) }) + }) describe('pressure measurement and retention', () => { @@ -254,7 +442,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', { @@ -265,6 +453,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')) @@ -503,8 +737,8 @@ describe('optional model-free tool-result pruning', () => { expect(await compactIfNeeded(compact, session)).not.toBeNull() expect(compact.calls).toHaveLength(1) - expect(compact.calls[0]!.text).toContain('tool result middle pruned') - expect(compact.calls[0]!.text).not.toContain('result 1 '.repeat(300)) + expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') + expect(summarizedText(compact.calls[0]!.input)).not.toContain('result 1 '.repeat(300)) }) it('retains the original compact-basic behavior without the optional plugin', async () => { @@ -541,7 +775,7 @@ describe('compaction region transaction', () => { expect(result.shadowedSeqs).toEqual(before.slice(0, 4)) expect(result.shadowedTokenCount).toBeGreaterThan(0) expect(compact.calls[0]).toMatchObject({ signal: SIGNAL }) - expect(compact.calls[0]?.text).toContain('fixture user 1') + expect(summarizedText(compact.calls[0]!.input)).toContain('fixture user 1') const summary = session.events.findLast(event => event.type === 'compact/summary') expect(summary?.data).toMatchObject({ shadowedSeqs: result.shadowedSeqs, @@ -559,6 +793,25 @@ describe('compaction region transaction', () => { expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) + it('replays the latest routed header prefix so the summarizer reuses the cache', async () => { + const compact = service() + const session = conversation(3) + const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] + const messagePrefix: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'SESSION PREFIX' }] }] + session.append('request/header', { + header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools, messagePrefix }, + reason: 'resume', + }) + const nodes = session.surface.nodes + await compact.compactRegion(nodes[0]!, nodes[1]!, agent(session, MODEL), SIGNAL) + + const { input } = compact.calls[0]! + expect(input.system).toBe('CONVERSATION SYSTEM') + expect(input.tools).toEqual(tools) + expect(input.messages[0]).toEqual(messagePrefix[0]) + expect(summarizedText(input)).toContain('fixture user 1') + }) + it.each([ ['start missing', 9_001, undefined, /start seq 9001 not found/], ['end missing', undefined, 9_002, /end seq 9002 not found/], @@ -772,11 +1025,11 @@ class ScriptedAdapter extends LlmAdapter { class ExposedCompactService extends BasicCompactService { runSummarize( - text: string, + input: SummarizationInput, owner: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { - return this.summarize(text, owner, signal) + return this.summarize(input, owner, signal) } } @@ -788,7 +1041,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) @@ -808,7 +1061,7 @@ describe('default one-shot summarizer', () => { maxTokens: 321, }) const session = conversation(1) - const output = await compact.runSummarize('transcript', agent(session, 'fallback'), SIGNAL) + const output = await compact.runSummarize(promptInput('transcript'), agent(session, 'fallback'), SIGNAL) expect(output).toEqual({ summary: [{ type: 'text', text: 'public summary' }], @@ -823,7 +1076,68 @@ describe('default one-shot summarizer', () => { signal: SIGNAL, sessionId: session.id, }) - expect(adapter.lastOptions?.system).toContain('## Primary Request and Intent') + const instruction = adapter.lastOptions?.messages.at(-1)?.content[0] + expect(instruction?.type === 'text' ? instruction.text : '').toContain('## Primary Request and Intent') + }) + + it('replays the conversation prefix and appends the instruction as the final message', async () => { + const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) + const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] + const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'earlier turn' }] } + await compact.runSummarize({ + system: 'REPLAYED SYSTEM', + tools, + messages: [prefix], + }, agent(conversation(1), MODEL)) + + expect(adapter.lastOptions?.system).toBe('REPLAYED SYSTEM') + expect(adapter.lastOptions?.tools).toEqual(tools) + const messages = adapter.lastOptions?.messages ?? [] + expect(messages[0]).toEqual(prefix) + const last = messages.at(-1)?.content[0] + const lastText = last?.type === 'text' ? last.text : '' + expect(lastText).toContain('Condense the conversation ABOVE') + expect(lastText).toContain('## Primary Request and Intent') + }) + + it('applies the routed model policy without changing the replayed prefix', async () => { + const { ctx, compact } = await summarizerHarness( + [{ type: 'text', text: 'unused default summary' }], + undefined, + MODEL, + { + auto: false, + maxTokens: 111, + modelPolicies: [{ + provider: MODEL, + model: MODEL, + summarizationProvider: 'policy-summary', + summarizationModel: 'policy-summary', + maxTokens: 222, + }], + }, + ) + const policyAdapter = new ScriptedAdapter([{ type: 'text', text: 'policy summary' }]) + ctx.llm.registerAdapter(['policy-summary'], policyAdapter) + const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'warm prefix' }] } + + const output = await compact.runSummarize({ + system: 'WARM SYSTEM', + messages: [prefix], + }, agent(conversation(1), 'fallback')) + + expect(output).toMatchObject({ + provider: 'policy-summary', + model: 'policy-summary', + maxTokens: 222, + }) + expect(policyAdapter.lastOptions).toMatchObject({ + provider: 'policy-summary', + model: 'policy-summary', + maxTokens: 222, + system: 'WARM SYSTEM', + }) + expect(policyAdapter.lastOptions?.messages[0]).toEqual(prefix) }) it('resolves the latest routed provider/model before the AgentOptions pair', async () => { @@ -833,7 +1147,7 @@ describe('default one-shot summarizer', () => { header: { config: { provider: 'routed', model: 'routed' } }, reason: 'initial', }) - const output = await compact.runSummarize('history', agent(session, 'fallback')) + const output = await compact.runSummarize(promptInput('history'), agent(session, 'fallback')) expect(output.provider).toBe('routed') expect(output.model).toBe('routed') expect(adapter.lastOptions?.provider).toBe('routed') @@ -867,7 +1181,32 @@ describe('default one-shot summarizer', () => { await ctx.plugin(LlmService) void new TokenMeterService(ctx) const compact = new ExposedCompactService(ctx, { auto: false }) - await expect(compact.runSummarize('history', agent(new Session(SessionId('model-less'))))) + await expect(compact.runSummarize(promptInput('history'), agent(new Session(SessionId('model-less'))))) + .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(promptInput('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(promptInput('history'), owner)) .rejects.toThrow(/no provider\/model available for summarization/) }) @@ -882,7 +1221,7 @@ describe('default one-shot summarizer', () => { const { compact } = await summarizerHarness([], finish) let thrown: unknown try { - await compact.runSummarize('history', agent(conversation(1), MODEL)) + await compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)) } catch (error: unknown) { thrown = error } @@ -894,14 +1233,14 @@ describe('default one-shot summarizer', () => { it('rejects empty or reasoning-only successful output', async () => { const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }]) - await expect(compact.runSummarize('history', agent(conversation(1), MODEL))) + await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) .rejects.toThrow(/no text summary content/) }) }) 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( @@ -914,7 +1253,9 @@ describe('automatic listener and loader composition', () => { ): Promise<{ action: 'fail' | 'retry' }> { const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure)) - return ctx.waterfall('agent/request-error', owner, 1, 1, error, failure, priorFailures, signal, next) + return agentEvents(ctx, owner).waterfall( + 'agent/request-error', 1, 1, error, failure, priorFailures, signal, next, + ) } function overflow(message = 'provider overflow'): Error & { code: string } { @@ -969,6 +1310,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, { @@ -1023,7 +1401,7 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) expect(compact.calls).toHaveLength(1) - expect(compact.calls[0]!.text).toContain('tool result middle pruned') + expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') }) it('retries from a durable prune when later overflow summarization throws', async () => { @@ -1184,6 +1562,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 }) @@ -1199,6 +1589,23 @@ describe('automatic listener and loader composition', () => { expect(compactSpy).not.toHaveBeenCalled() }) + it('applies the routed model override to the overflow retry cap', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx, { + maxOverflowRetries: 2, + modelPolicies: [{ + provider: MODEL, + model: MODEL, + maxOverflowRetries: 1, + }], + }) + const compactSpy = vi.spyOn(compact, 'compactIfNeeded') + + expect(await recover(ctx, agent(conversation(3), MODEL), overflow(), 1)) + .toEqual({ action: 'fail' }) + expect(compactSpy).not.toHaveBeenCalled() + }) + it('does not retry when cancellation lands during an awaited compaction', async () => { const ctx = createContext() const compact = new TestCompactService(ctx) @@ -1246,7 +1653,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() @@ -1257,7 +1663,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 e899979f46..4b3dd7a6b3 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 * as LlmRetry from '@deepseek-ai/dsh-llm-retry' @@ -38,6 +41,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 @@ -69,8 +76,17 @@ 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')) { + // The cache-reusing summarizer replays the conversation prefix and marks + // its call only by the compaction instruction in the trailing user message. + const trailing = options.messages.at(-1)?.content + .map(block => (block.type === 'text' ? block.text : '')) + .join('') ?? '' + if (trailing.includes('acting as a compaction engine')) { this.summaryRequests.push(options) yield { type: 'block-start', index: 0, blockType: 'text' } yield { type: 'block-end', index: 0, block: { type: 'text', text: 'RECOVERY CHECKPOINT' } } @@ -104,12 +120,19 @@ 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 }) + await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) ctx.tools.register(defineTool({ name: 'work', @@ -125,7 +148,6 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr auto: true, thresholdRatio: 0.5, retainTokens: 50, - summarizationModel: '', maxTokens: 8192, compactionRetries: 1, }) @@ -255,9 +277,9 @@ 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 }) + 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, { @@ -317,7 +339,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () const ctx = new Context() const adapter = new OverflowRecoveryAdapter('thrown', true) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) + await mountInvariants(ctx) await ctx.plugin(LlmRetry, { maxTransientRetries: 1, initialDelayMs: 1, @@ -325,7 +347,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () jitterRatio: 0, }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(TokenMeterService, { contextWindow: 128 }) + await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], adapter) await ctx.plugin(BasicCompactService, { thresholdRatio: 1, diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index 2035627f64..74f8423e52 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -56,8 +56,6 @@ 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-tool-result-prune'", ' config:', ' thresholdChars: 100', @@ -66,7 +64,7 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-compact-basic'", ' config:', ' thresholdRatio: 0.5', - ' retainTokens: 512', + ' retainRatio: 0.125', ' auto: false', ]) @@ -74,12 +72,11 @@ 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('toolResultPrune')).toBeInstanceOf(ToolResultPruneService) expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService) expect((loaded.compact as BasicCompactService).config).toMatchObject({ thresholdRatio: 0.5, - retainTokens: 512, + retainRatio: 0.125, auto: false, }) }) @@ -87,8 +84,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, + } as never)).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/) }) it('rejects stale compact-basic config after Schemastery normalization', async () => { @@ -99,4 +96,33 @@ 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\)/) + }) + + 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/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json index 47d552c3f0..bd1a440119 100644 --- a/packages/compact/compact-basic/tsconfig.json +++ b/packages/compact/compact-basic/tsconfig.json @@ -6,14 +6,35 @@ }, "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": "../compact-tool-result-prune" } + { + "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" + }, + { + "path": "../compact-tool-result-prune" + } ] } diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index 81c81eb894..7cd9ff6b98 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/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" diff --git a/packages/compact/compact-tool-result-prune/src/invariant.ts b/packages/compact/compact-tool-result-prune/src/invariant.ts new file mode 100644 index 0000000000..8c2b0a1133 --- /dev/null +++ b/packages/compact/compact-tool-result-prune/src/invariant.ts @@ -0,0 +1,27 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-compact-tool-result-prune`. + * @module @deepseek-ai/dsh-compact-tool-result-prune/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-compact-tool-result-prune' + +/** Cordis companion plugin name. */ +export const name = 'compact-tool-result-prune-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** No runtime invariant: Session validates each content-only rewrite and its companion owns cross-event enclosure. */ +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-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index bc382c8e4e..6c308665fd 100644 --- a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -4,7 +4,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SurfaceEvent } from '@deepseek-ai/dsh-session' -import * as Invariants from '@deepseek-ai/dsh-invariants' +import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' import ToolResultPruneService, { codePointLength, DEFAULTS, @@ -225,7 +226,8 @@ describe('ToolResultPruneService session transaction', () => { it('runs under real invariants between closed steps but not outside a turn', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(Invariants) + await ctx.plugin(InvariantService) + await ctx.plugin(SessionInvariant) const prune = new ToolResultPruneService(ctx, SMALL) const session = ctx.sessions.create(SessionId('invariants')) appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }]) diff --git a/packages/compact/compact-tool-result-prune/tsconfig.json b/packages/compact/compact-tool-result-prune/tsconfig.json index e021fa336e..a6c2e5124b 100644 --- a/packages/compact/compact-tool-result-prune/tsconfig.json +++ b/packages/compact/compact-tool-result-prune/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, { "path": "../../llm/llm" }, - { "path": "../../core/session" } + { "path": "../../core/session" }, + { "path": "../../support/invariants" } ] } diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 6e33b6e570..ed785b41ff 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c | Package | Role | |---|---| -| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) | +| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers | | `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | @@ -63,7 +63,7 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and #### What the model sees -A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite. +A successful implementation replaces an older surface range with one user-role summary checkpoint — a `user/message` carrying `surfaceOp: { op: 'replace', start, end }`; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite. #### Token effect @@ -73,20 +73,6 @@ Zero direct tokens from this interface. A backend trades many retained history t A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request. -### Transcript supplied to a compaction consumer - -#### What the model sees - -`renderTranscript()` joins entries with one blank line and renders them exactly as `User: `, `Assistant: `, `Tool result (call ): `, `Tool error (call ): `, `[Context: ]`, or `[Steering: ]`. Non-text blocks render exactly as `[reasoning: ]`, `[tool-call: ()]`, `[tool-result: ]`, `[tool-result]`, or `[]`. - -#### Token effect - -Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript. - -#### KV Cache effect - -No conversation-cache invalidation. A consumer's auxiliary request can reuse only the exact prefix produced by this rendering; changed or compacted entries invalidate reuse from their first difference. - ## Known Limitations and Deferred Work - **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener. 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/index.ts b/packages/compact/compact/src/index.ts index f4f666bfef..2a9d7955af 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -12,7 +12,6 @@ import type { Session } from '@deepseek-ai/dsh-session' import type { CompactionResult } from './types.ts' export type { CompactionResult } from './types.ts' -export { renderContentBlocks, renderTranscript } from './render.ts' export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts' /** Why automatic policy is asking a backend to consider compaction. */ diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts new file mode 100644 index 0000000000..da5d5eba5a --- /dev/null +++ b/packages/compact/compact/src/invariant.ts @@ -0,0 +1,111 @@ +/** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +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. */ +// 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() + 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'] }) +/* jscpd:ignore-end */ + +/** + * Register the compact 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/compact/compact/src/render.ts b/packages/compact/compact/src/render.ts deleted file mode 100644 index 48006d13b7..0000000000 --- a/packages/compact/compact/src/render.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Pure shared transcript projection for summarization and recall, so both - * render the same log span byte-for-byte under replay. - * @module @deepseek-ai/dsh-compact/render - */ - -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' - -/** - * Render text directly, reasoning as a tagged span, and every other block as a - * type-tagged placeholder. Tool results recurse into nested content; empty - * blocks contribute nothing and rendered blocks join with newlines. - * - * @param blocks - the content blocks to render. - * @returns the newline-joined plain-text rendering; empty string when nothing renders. - */ -export function renderContentBlocks(blocks: readonly ContentBlock[]): string { - const parts: string[] = [] - for (const block of blocks) { - switch (block.type) { - case 'text': - if (block.text) parts.push(block.text) - break - case 'reasoning': - if (block.text) parts.push(`[reasoning: ${block.text}]`) - break - case 'tool-call': - parts.push(`[tool-call: ${block.name}(${block.arguments})]`) - break - case 'tool-result': { - const inner = renderContentBlocks(block.content) - parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]') - break - } - // ContentBlockMap is merge-extensible — render an unknown block as a - // bare type-tagged placeholder so a plugin-added block type is still - // signalled to the reader rather than dropped. - default: - parts.push(`[${(block as ContentBlock).type}]`) - } - } - return parts.join('\n') -} - -/** - * Render message-producing events as a role-labeled transcript. `seqs` are - * walked in caller-supplied surface order, which may differ from numeric log - * order after replacement; non-surface and unknown merged events are skipped. - * - * @param events - the session log the seqs index into (`session.events`). - * @param seqs - the surface-node seqs to render, in surface order. - * @returns the transcript, entries joined by blank lines; empty string when nothing renders. - */ -export function renderTranscript(events: readonly SessionEvent[], seqs: readonly number[]): string { - const lines: string[] = [] - - for (const seq of seqs) { - const event = events[seq] - if (!event) continue - - switch (event.type) { - case 'user/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`User: ${text}`) - break - } - case 'assistant/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`Assistant: ${text}`) - break - } - case 'tool/result': { - const text = renderContentBlocks(event.data.content) - const label = event.data.isError ? 'Tool error' : 'Tool result' - if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`) - break - } - case 'context/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`[Context: ${text}]`) - break - } - case 'steering/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`[Steering: ${text}]`) - break - } - default: - break - } - } - - return lines.join('\n\n') -} 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/compact/compact/tests/render.spec.ts b/packages/compact/compact/tests/render.spec.ts deleted file mode 100644 index 3b4bb41ac2..0000000000 --- a/packages/compact/compact/tests/render.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { renderContentBlocks, renderTranscript } from '@deepseek-ai/dsh-compact' -import { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { CallId } from '@deepseek-ai/dsh-llm' - -function session(): Session { - return new Session(SessionId('render-spec')) -} - -describe('renderContentBlocks', () => { - it('renders text blocks verbatim and skips empty ones', () => { - expect(renderContentBlocks([ - { type: 'text', text: 'hello' }, - { type: 'text', text: '' }, - { type: 'text', text: 'world' }, - ])).toBe('hello\nworld') - }) - - it('wraps reasoning, skipping empty reasoning', () => { - expect(renderContentBlocks([ - { type: 'reasoning', text: 'think' }, - { type: 'reasoning', text: '' }, - ])).toBe('[reasoning: think]') - }) - - it('renders tool-call as a name(args) placeholder', () => { - expect(renderContentBlocks([ - { type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"filePath":"a"}' }, - ])).toBe('[tool-call: read({"filePath":"a"})]') - }) - - it('renders tool-result with nested content, and bare when empty', () => { - expect(renderContentBlocks([ - { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] }, - { type: 'tool-result', toolCallId: CallId('c2'), content: [] }, - ])).toBe('[tool-result: ok]\n[tool-result]') - }) - - it('renders an unknown (merge-extended) block type as a bare type tag', () => { - const unknown = { type: 'image', data: 'zzz' } as unknown as ContentBlock - expect(renderContentBlocks([unknown])).toBe('[image]') - }) - - it('returns the empty string for no blocks', () => { - expect(renderContentBlocks([])).toBe('') - }) -}) - -describe('renderTranscript', () => { - it('renders each surface event type with its label, in the seq order given', () => { - const s = session() - const user = s.append('user/message', { - content: [{ type: 'text', text: 'fix the bug' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - const assistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, - turn: 0, step: 0, - content: [{ type: 'text', text: 'looking' }], - }, { surfaceOp: 'append' }) - const result = s.append('tool/result', { - turn: 0, step: 0, callId: CallId('c1'), - content: [{ type: 'text', text: 'exit 0' }], - isError: false, - }, { surfaceOp: 'append' }) - const context = s.append('context/message', { - content: [{ type: 'text', text: 'file changed' }], - source: { kind: 'plugin', plugin: 'fs' }, - }, { surfaceOp: 'append' }) - const steering = s.append('steering/message', { - turn: 0, - content: [{ type: 'text', text: 'stop that' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - - expect(renderTranscript(s.events, [user.seq, assistant.seq, result.seq, context.seq, steering.seq])).toBe([ - 'User: fix the bug', - 'Assistant: looking', - 'Tool result (call c1): exit 0', - '[Context: file changed]', - '[Steering: stop that]', - ].join('\n\n')) - }) - - it('labels an error tool result "Tool error"', () => { - const s = session() - const result = s.append('tool/result', { - turn: 0, step: 0, callId: CallId('c9'), - content: [{ type: 'text', text: 'boom' }], - isError: true, - }, { surfaceOp: 'append' }) - expect(renderTranscript(s.events, [result.seq])).toBe('Tool error (call c9): boom') - }) - - it('renders NON-log-order seqs in the order given (surface order after a replace)', () => { - const s = session() - const first = s.append('user/message', { - content: [{ type: 'text', text: 'first' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - const second = s.append('user/message', { - content: [{ type: 'text', text: 'second' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - expect(renderTranscript(s.events, [second.seq, first.seq])).toBe('User: second\n\nUser: first') - }) - - it('skips events that render to nothing, non-message events, and seqs with no event', () => { - const s = session() - const empty = s.append('user/message', { - content: [{ type: 'text', text: '' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - const emptyAssistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, - turn: 0, step: 0, - content: [{ type: 'text', text: '' }], - }, { surfaceOp: 'append' }) - const emptyResult = s.append('tool/result', { - turn: 0, step: 0, callId: CallId('c3'), - content: [{ type: 'text', text: '' }], - isError: false, - }, { surfaceOp: 'append' }) - const emptyContext = s.append('context/message', { - content: [{ type: 'text', text: '' }], - source: { kind: 'plugin', plugin: 'fs' }, - }, { surfaceOp: 'append' }) - const emptySteering = s.append('steering/message', { - turn: 0, - content: [{ type: 'text', text: '' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - // A log-only (non-surface) event type: contributes nothing to a transcript. - const lock = s.append('compact/start', { turn: 0 }) - expect(renderTranscript(s.events, [ - empty.seq, emptyAssistant.seq, emptyResult.seq, emptyContext.seq, emptySteering.seq, lock.seq, 9999, - ])).toBe('') - }) -}) 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/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/package.json b/packages/context/time-context/package.json index c0d69a75cb..2b3ad9fca6 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,15 @@ }, "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": { "@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..45fdb48cba --- /dev/null +++ b/packages/context/time-context/src/invariant.ts @@ -0,0 +1,114 @@ +/** Package-owned durable clock-context invariants. @module @deepseek-ai/dsh-time-context/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** Derive the pre-step position at which a time-context reading may append. */ +function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } { + const currentTurnEvents: SessionEvent[] = [] + let openTurn: number | undefined + for (const event of history.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( + history: readonly SessionEvent[], + 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 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}`) + } + 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) { + fail('time-context rendered timestamp must parse and not postdate its durable event') + } +} + +/* 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.events, event, fail) + }, { global: true }) +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ + +/** + * Register the time-context 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/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts new file mode 100644 index 0000000000..5da942603f --- /dev/null +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +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' + +const SECOND = Date.parse('2026-07-14T00:00:00Z') + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) + 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.` +} + +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 +} + +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() + 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', 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('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/], + ])('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([ + ['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, /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/], + ] 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', preparing(1, preparationStep), 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', preparing(1, 1), other) }).not.toThrow() + other.data.source = { kind: 'user' } + expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow() + expect(() => { + 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') + }).not.toThrow() + }) +}) 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..3f3c553e98 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -6,13 +6,35 @@ }, "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" + }, + { + "path": "../../core/session" + } ] } 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..d9f56417b8 --- /dev/null +++ b/packages/context/workspace-context/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 8b0320bcfc..e842dc98af 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, @@ -225,14 +231,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) } @@ -520,6 +543,7 @@ describe('workspace context instruction discovery', () => { vi.resetModules() vi.doMock('node:os', () => ({ homedir: () => home })) + vi.stubEnv('DSH_HOME', undefined) const isolated = await import('@deepseek-ai/dsh-workspace-context') const files = await isolated.discoverBaselineInstructionFiles({ cwd: root }) @@ -527,6 +551,7 @@ describe('workspace context instruction discovery', () => { } finally { vi.doUnmock('node:os') vi.resetModules() + vi.unstubAllEnvs() await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) } @@ -793,7 +818,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' }, @@ -840,7 +865,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' }], })) @@ -854,7 +879,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') @@ -1166,8 +1191,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), ) @@ -1657,7 +1683,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 })) @@ -2383,12 +2409,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 })) @@ -2622,19 +2648,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) @@ -2670,7 +2696,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/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 792094777e..c4a106b1f7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -314,6 +314,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 - listener or startup-check 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.', @@ -330,6 +340,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 */', @@ -1341,6 +1355,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InjectOptions', declaration: 'export interface InjectOptions extends SendOptions {\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 | Promise;\n readonly inject?: Inject;\n}', + }, { name: 'JsonValue', declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};', @@ -1353,6 +1375,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmFailure', declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\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/cordis/tool-cordis/src/invariant.ts b/packages/cordis/tool-cordis/src/invariant.ts new file mode 100644 index 0000000000..6fd73d0353 --- /dev/null +++ b/packages/cordis/tool-cordis/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/README.md b/packages/core/agent-loop/README.md index f4193d44ad..1197a018be 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`. 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) ```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..17e55af6ca --- /dev/null +++ b/packages/core/agent-loop/src/invariant.ts @@ -0,0 +1,76 @@ +/** + * 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' +import { isLoopRequest } from './request-marker.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop' + +/** Cordis companion plugin name. */ +export const name = 'agent-loop-invariant' +/** 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) => { + // 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 (!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) 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') + } + + 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 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-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/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 7ed8c2075e..6900faddb0 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[] = [] @@ -1041,7 +1051,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) @@ -1050,7 +1060,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 } @@ -1468,7 +1478,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) => ({ @@ -1505,7 +1515,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. @@ -1556,7 +1566,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) { @@ -1611,7 +1621,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 () => { @@ -1662,7 +1672,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 () => { @@ -1711,7 +1721,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..c5030bf161 --- /dev/null +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -0,0 +1,130 @@ +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' +import { markLoopRequest } from '../src/request-marker.ts' + +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) +} + +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')) + 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 = 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 = loopRequest({ 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, loopRequest({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) }) + .not.toThrow() + expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) }) + .toThrow(/diverges from the boundary derivation/) + 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, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) }) + .toThrow(/diverges from the boundary derivation/) + expect(() => { dispatch(ctx, loopRequest({ 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 = 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/) + }) + + it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => { + const { ctx, session, boundary } = await requestSetup() + 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 () => { + 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 = loopRequest({ + 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/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 0547388d96..a218987e0e 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 Agent Note: AgentLoop factory create/resume', seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length, delegationDepth: 1 }, }) - 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 @@ -485,7 +485,7 @@ describe('the session-persistence Agent Note: 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/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 72de35107b..269190dab0 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -2,13 +2,15 @@ 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. ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model selection during prompt assembly and applies that pair to both prompt variables and request routing for one step. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. 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/index.ts b/packages/core/agent/src/index.ts index 061ddae53f..66dce92464 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -15,6 +15,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' +export * from './llm-target.ts' export { agentEvents, assembleContextFor } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' 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/src/llm-target.ts b/packages/core/agent/src/llm-target.ts new file mode 100644 index 0000000000..98f1e19d7d --- /dev/null +++ b/packages/core/agent/src/llm-target.ts @@ -0,0 +1,66 @@ +/** + * Agent-scoped provider/model target snapshot shared by interactive front doors. + * @module @deepseek-ai/dsh-agent/llm-target + */ + +import type { Context } from 'cordis' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' + +/** Complete provider/model route selected for one live agent. */ +export interface AgentLlmTarget { + /** Registered provider route. */ + provider: string + /** Provider-owned model id. */ + model: string +} + +/** Mutable selection plus the target captured for the current step. */ +export interface AgentLlmTargetRef { + /** Target selected for the next step that enters prompt assembly. */ + current: AgentLlmTarget | undefined + /** Target captured when the current step entered prompt assembly. */ + assembled: AgentLlmTarget | undefined +} + +/** + * Couple one mutable target to agent-scoped prompt assembly and request routing. + * Prompt assembly snapshots the selected pair before delegating, then applies + * both prompt variables and request config to that snapshot so a concurrent + * switch takes effect on a later step instead of splitting the two surfaces. + * + * @param agentCtx - The target agent's scoped context. + * @param target - Mutable selection owned by the calling front door. + * @returns Disposer for both scoped waterfall listeners. + */ +export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetRef): () => void { + const disposeAssembly = agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const selected = target.current + const assembled = await next() + target.assembled = selected + if (selected === undefined) return assembled + return { + ...assembled, + variables: { + ...assembled.variables, + provider: selected.provider, + model: selected.model, + }, + } + }) + const disposeRequest = agentCtx.on( + 'agent/request', + async (_agent, _turn, _step, _config, next): Promise => { + const resolved = await next() + const selected = target.assembled + return selected === undefined ? resolved : { + ...resolved, + provider: selected.provider, + model: selected.model, + } + }, + ) + return () => { + disposeAssembly() + disposeRequest() + } +} 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/tests/llm-target.spec.ts b/packages/core/agent/tests/llm-target.spec.ts new file mode 100644 index 0000000000..0c9ccb56ec --- /dev/null +++ b/packages/core/agent/tests/llm-target.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import { + agentEvents, + installAgentLlmTarget, + type Agent, + type AgentLlmTargetRef, +} from '../src/index.ts' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' + +describe('installAgentLlmTarget()', () => { + it('snapshots prompt variables and request routing together, then disposes both listeners', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + const target: AgentLlmTargetRef = { current: undefined, assembled: undefined } + const dispose = installAgentLlmTarget(ctx, target) + const agent = {} as Agent + const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } + + expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + )).resolves.toBe(seed) + + target.current = { provider: 'alpha', model: 'a1' } + expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) + target.current = { provider: 'beta', model: 'b1' } + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + )).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 }) + + expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' }) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 1, 1, seed, () => Promise.resolve(seed), + )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) + + dispose() + expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', 2, 0, seed, () => Promise.resolve(seed), + )).resolves.toBe(seed) + await ctx.fiber.dispose() + }) +}) 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 fd6d10c84c..7fc4a49102 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 Agent Note](../../../.agents/notes/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..5988b58145 --- /dev/null +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -0,0 +1,52 @@ +/** + * 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/cancel-requested': args => args[0], + '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'], + 'goal/changed': args => args[0], + '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..fd619402d0 --- /dev/null +++ b/packages/core/scope/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 * 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')]], + ['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]], + ['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 28210e8f6c..c618e78301 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, complete replacement coverage, and content-only single-result `tool/result` rewrites, 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..aa8aa91531 --- /dev/null +++ b/packages/core/session/src/invariant.ts @@ -0,0 +1,238 @@ +/** + * 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** 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': { + // Session has already validated a provenance-backed content rewrite. + // It is durable turn work, not a second execution of the original call. + if (event.surfaceOp !== 'append') { + if (trace.openTurn === null) { + fail('tool/result surface replacement appended outside any open turn') + } + break + } + 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 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/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts new file mode 100644 index 0000000000..bc0a79759d --- /dev/null +++ b/packages/core/session/tests/invariant.spec.ts @@ -0,0 +1,344 @@ +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('keeps fresh tool-result appends open-step checked', 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('tool/result', { + turn: 1, + step: 1, + callId: CallId('closed'), + content: [], + isError: false, + }, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/) + }) + + it('treats a validated tool-result replacement as a turn-enclosed rewrite', 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('rewrite'), + name: 'echo', + arguments: '{}', + }) + const original = session.append('tool/result', { + turn: 1, + step: 1, + callId: CallId('rewrite'), + content: [{ type: 'text', text: 'original' }], + isError: false, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => session.append('tool/result', { + ...original.data, + content: [{ type: 'text', text: 'pruned' }], + }, { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + })).not.toThrow() + }) + + it('rejects a tool-result replacement outside 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('tool/call', { + turn: 1, + step: 1, + callId: CallId('rewrite'), + name: 'echo', + arguments: '{}', + }) + const original = session.append('tool/result', { + turn: 1, + step: 1, + callId: CallId('rewrite'), + content: [{ type: 'text', text: 'original' }], + isError: false, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + expect(() => session.append('tool/result', { + ...original.data, + content: [{ type: 'text', text: 'pruned' }], + }, { + surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, + sourceEventSeqs: [original.seq], + })).toThrow(/outside any open turn/) + }) + + 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/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index a17981146d..0c41af5b46 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -717,10 +717,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]) @@ -732,6 +733,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()) @@ -973,8 +975,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 () => { @@ -1058,6 +1061,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' }, @@ -1077,19 +1082,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/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')) 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/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..e199cc98b4 --- /dev/null +++ b/packages/core/system-prompt/src/invariant.ts @@ -0,0 +1,52 @@ +/** Package-owned prompt-assembly invariants. @module @deepseek-ai/dsh-system-prompt/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** 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) => { + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const assembled = await next() + validateAssembly(assembled, fail) + return assembled + }, { global: true, prepend: true }) +} + +/** + * Register the system-prompt 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/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/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..2f5f27a281 --- /dev/null +++ b/packages/core/tools/src/invariant.ts @@ -0,0 +1,69 @@ +/** Package-owned tool-pipeline invariants. @module @deepseek-ai/dsh-tools/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +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) => { + 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 the tools 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/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index aaceb99fce..54a9d8e794 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { 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/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 1343e9f713..370dd39fbf 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,32 +37,34 @@ "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-commands": "^0.0.1", "@deepseek-ai/dsh-command-goal": "^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-commands": "workspace:^", "@deepseek-ai/dsh-command-goal": "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..95b57b57e1 --- /dev/null +++ b/packages/examples/acp-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 52ad60449e..01053a049a 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/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json index d3fc190640..5bd1627345 100644 --- a/packages/examples/acp-demo/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -46,6 +46,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/README.md b/packages/examples/agent-spine-demo/README.md index 4b271ccfbc..f7bc0a98c8 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -22,7 +22,12 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-goal-session optional same-session goal-round driver @deepseek-ai/dsh-llm-retry bounded transient request retry policy @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 @@ -46,11 +51,13 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, llmRetry? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? } // 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 — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `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. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. 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 enabled 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; 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 — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `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. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. 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 enabled 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; 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 @@ -69,4 +76,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit bundled goals, skills, and task-control tools, but swapping the loop or dropping another 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 57d6a0cffa..4882aede9a 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" @@ -27,11 +32,11 @@ "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-goal-session": "^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-llm-retry": "^0.0.1", - "@deepseek-ai/dsh-workspace-context": "^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", @@ -42,6 +47,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": { @@ -54,13 +60,13 @@ "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-home": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", @@ -72,6 +78,7 @@ "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "node-addon-landlock-run": "0.0.0-test.0", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index b4724ac607..049bdc9076 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -22,7 +22,11 @@ import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-g import * as goalSession from '@deepseek-ai/dsh-goal-session' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' 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' @@ -63,8 +67,9 @@ export interface GoalConfig { * skill registry/local provider/tool consumer, `workspaceContext` to the * workspace-context loader, `llmRetry` to the bounded request-recovery policy, * and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. - * `goals` opts into and configures the persisted goal - * domain plus its model tool and same-session driver. Owner schemas supply defaults for optional input; + * `goals` opts into and configures the persisted goal domain plus its model tool + * and same-session driver; `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 @@ -91,6 +96,8 @@ export interface Config { toolBash?: toolBash.Config /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false + /** Global enablement and package-name filters for invariant companions. */ + invariants?: InvariantConfig /** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */ goals?: GoalConfig | false /** Bounded transient model-request retry policy. */ @@ -131,9 +138,10 @@ export const Config = z.intersect([ workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), + invariants: InvariantService.Config, goals: z.union([z.const(false), GoalConfigSchema]), llmRetry: LlmRetryConfigSchema, - }) as unknown as z>, + }) as unknown as z>, ]) as unknown as z /** @@ -152,6 +160,7 @@ export function pickSpineConfig(config: Omit): Omit {} + +/** + * 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/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index c8788ca01b..94253523ce 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -12,6 +12,10 @@ import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { CallId, LlmAdapter, LlmError, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' +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' declare module '@deepseek-ai/dsh-tasks' { interface TaskKindMap { @@ -44,7 +48,14 @@ async function mount(config: agentCore.Config, withBash = false): Promise { 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() expect(ctx.get('goals')).toBeUndefined() await ctx.fiber.dispose() @@ -156,6 +168,28 @@ describe('dsh-agent-spine-demo bundle', () => { 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('loads and configures bounded request recovery for every bundled front door', async () => { const adapter = new TransientOnceAdapter() const ctx = await mount({ @@ -457,6 +491,7 @@ describe('dsh-agent-spine-demo bundle', () => { skills: { enabled: false }, toolBash: { enableRunInBackground: false }, toolTasks: false as const, + invariants: { enabled: false }, llmRetry: { maxTransientRetries: 1, jitterRatio: 0 }, } @@ -469,6 +504,7 @@ describe('dsh-agent-spine-demo bundle', () => { skills: appConfig.skills, toolBash: appConfig.toolBash, toolTasks: appConfig.toolTasks, + invariants: appConfig.invariants, llmRetry: appConfig.llmRetry, }) expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false }) @@ -530,4 +566,16 @@ describe('dsh-agent-spine-demo bundle', () => { expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) + + 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) + 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/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..8eb40e9268 --- /dev/null +++ b/packages/examples/cli-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts index c248242ad1..554fee8bf1 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/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..dd093a5418 --- /dev/null +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/tui-demo/package.json b/packages/examples/tui-demo/package.json index be1cc01889..bcaa64c984 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-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", @@ -37,15 +42,16 @@ "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-command-goal": "^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-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-tui": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^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" }, @@ -57,16 +63,17 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-command-goal": "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-invariants": "workspace:^", + "@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:^", "@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/tui-demo/src/invariant.ts b/packages/examples/tui-demo/src/invariant.ts new file mode 100644 index 0000000000..1bb55546bf --- /dev/null +++ b/packages/examples/tui-demo/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tui-demo`. + * @module @deepseek-ai/dsh-tui-demo/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tui-demo' + +/** Cordis companion plugin name. */ +export const name = 'tui-demo-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this composition-only package delegates mutable state and event streams + * to the agent spine, persistence, and TUI packages that own their 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/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json index 7be6265128..21e6eff01f 100644 --- a/packages/examples/tui-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../session-persistence/session-persistence-jsonl" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/examples/tui-demo/tsdown.config.ts b/packages/examples/tui-demo/tsdown.config.ts index b3929c6d76..06efc0b4db 100644 --- a/packages/examples/tui-demo/tsdown.config.ts +++ b/packages/examples/tui-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/README.md b/packages/fs/README.md index 7b5561ca4d..161387160e 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -8,7 +8,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona | `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | | `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call mode + workspace root policy (read-only denies, workspace-write contains to the session workspace + temp roots), reads pass through | (registers `ctx.fs`) | | `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | -| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); resolves relative paths from the filesystem identity of the session cwd and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) | +| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) | | `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) | The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents). 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..3e38550065 --- /dev/null +++ b/packages/fs/fs-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..369fa5ea84 --- /dev/null +++ b/packages/fs/fs-policy/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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-sandbox/package.json b/packages/fs/fs-sandbox/package.json index e0fc7656ef..2fa4ee3f5d 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-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-fs": "^0.0.1", "@deepseek-ai/dsh-fs-local": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -31,6 +37,7 @@ "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/fs/fs-sandbox/src/invariant.ts b/packages/fs/fs-sandbox/src/invariant.ts new file mode 100644 index 0000000000..93806bd519 --- /dev/null +++ b/packages/fs/fs-sandbox/src/invariant.ts @@ -0,0 +1,27 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-fs-sandbox`. + * @module @deepseek-ai/dsh-fs-sandbox/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-fs-sandbox' + +/** Cordis companion plugin name. */ +export const name = 'fs-sandbox-invariant' +/** Services required before the companion can register. */ +export const inject = ['invariants'] + +/** No runtime invariant: this stateless adapter delegates policy and filesystem relations to their owning seams. */ +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-sandbox/tsconfig.json b/packages/fs/fs-sandbox/tsconfig.json index c9e2f629d5..40213fd6e1 100644 --- a/packages/fs/fs-sandbox/tsconfig.json +++ b/packages/fs/fs-sandbox/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index a88e89b72f..41de454488 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,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-sandbox": "^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-sandbox": "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..429c1cec41 --- /dev/null +++ b/packages/fs/fs/src/invariant.ts @@ -0,0 +1,39 @@ +/** Package-owned filesystem event-data invariants. @module @deepseek-ai/dsh-fs/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** 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) => { + 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 the filesystem 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/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/fs/tsconfig.json b/packages/fs/fs/tsconfig.json index eb981277c7..9ba6411797 100644 --- a/packages/fs/fs/tsconfig.json +++ b/packages/fs/fs/tsconfig.json @@ -6,10 +6,23 @@ }, "include": ["src"], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../util/brand" }, - { "path": "../../llm/llm" }, - { "path": "../../sandbox/sandbox" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../sandbox/sandbox" + } ] } 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..f7f206896d --- /dev/null +++ b/packages/fs/tool-fs-search/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 2b8d0871ad..737f7ac26b 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-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", @@ -40,9 +46,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-sandbox": "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..eaa2485c06 --- /dev/null +++ b/packages/fs/tool-fs/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/src/session-cwd.ts b/packages/fs/tool-fs/src/session-cwd.ts index c9f86ed196..b38f4109b5 100644 --- a/packages/fs/tool-fs/src/session-cwd.ts +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -11,6 +11,8 @@ import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { canonicalPath } from '@deepseek-ai/dsh-sandbox' +const PARENT_PATH_SEGMENT = /(?:^|[\\/])\.\.(?:[\\/]|$)/ + /** * The session workspace cwd for this call, or `undefined` when none applies. * @param exec - the tool-execution context; only its optional `agent` is read. @@ -18,7 +20,8 @@ import { canonicalPath } from '@deepseek-ai/dsh-sandbox' */ export function sessionCwd(exec: ToolExecution): string | undefined { const cwd = exec.agent?.session.header.cwd - return cwd === undefined ? undefined : canonicalPath(cwd) + if (cwd === undefined || !PARENT_PATH_SEGMENT.test(cwd)) return cwd + return canonicalPath(cwd) } /** diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 4d71e9cd05..ecf33e4d6d 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -5,6 +5,8 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import { realpathSync } from 'node:fs' +import { sep } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -24,6 +26,7 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { STREAM_MIN_SIZE } from '../src/read.ts' import { formatReadOutput } from '../src/read-render.ts' import type { FileReadOutcome } from '../src/read-render.ts' +import { sessionCwd } from '../src/session-cwd.ts' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -105,6 +108,20 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } +describe('session cwd resolution', () => { + const execution = (cwd?: string) => cwd === undefined + ? {} + : { agent: { session: { header: { cwd } } } } + + it('retains ordinary spelling but resolves parent segments before lexical use', () => { + const cwd = process.cwd() + const throughParent = `${cwd}${sep}..` + expect(sessionCwd(execution() as never)).toBeUndefined() + expect(sessionCwd(execution(cwd) as never)).toBe(cwd) + expect(sessionCwd(execution(throughParent) as never)).toBe(realpathSync.native(throughParent)) + }) +}) + describe('registration', () => { it('registers read, write, and edit', async () => { const { ctx } = await setup() diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index d2adddae03..fb420b553c 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -6,16 +6,41 @@ }, "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": "../../sandbox/sandbox" }, - { "path": "../../sandbox/sandbox-policy" }, - { "path": "../../ui/user-approval" } + { + "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" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../ui/user-approval" + } ] } diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 87da2ffaea..b1007d79f3 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/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-commands": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -31,6 +37,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "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/goal/command-goal/src/invariant.ts b/packages/goal/command-goal/src/invariant.ts new file mode 100644 index 0000000000..795294b4e8 --- /dev/null +++ b/packages/goal/command-goal/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-command-goal`. + * @module @deepseek-ai/dsh-command-goal/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-command-goal' + +/** Cordis companion plugin name. */ +export const name = 'command-goal-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this command adapter owns no event stream or state projection; accepted + * mutations are checked by the goal domain and command dispatch behavior is covered by package tests. + */ +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/goal/command-goal/tsconfig.json b/packages/goal/command-goal/tsconfig.json index 914a50c326..8b03235814 100644 --- a/packages/goal/command-goal/tsconfig.json +++ b/packages/goal/command-goal/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../goal" + }, + { + "path": "../../support/invariants" } ] } diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json index 48a77019a3..190cb795ad 100644 --- a/packages/goal/goal-session/package.json +++ b/packages/goal/goal-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" @@ -24,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-goal": "^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" @@ -33,6 +39,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-goal": "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/goal/goal-session/src/invariant.ts b/packages/goal/goal-session/src/invariant.ts new file mode 100644 index 0000000000..53cdd9b20b --- /dev/null +++ b/packages/goal/goal-session/src/invariant.ts @@ -0,0 +1,84 @@ +/** Package-owned goal-round prompt invariants. @module @deepseek-ai/dsh-goal-session/invariant */ + +import { isDeepStrictEqual } from 'node:util' +import type { Context } from 'cordis' +import { foldGoal, type FoldedGoal, type GoalMessageSource, type GoalView } from '@deepseek-ai/dsh-goal' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { renderGoalRoundPrompt } from './prompt.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-goal-session' + +/** Cordis companion plugin name. */ +export const name = 'goal-session-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** Attribute strict goal-fold failures to this companion's reconstruction. */ +function foldChecked(events: readonly SessionEvent[], fail: InvariantFailure): FoldedGoal { + try { + return foldGoal(events) + } catch (error: unknown) { + /* v8 ignore next -- the strict goal decoder throws Error instances */ + const message = error instanceof Error ? error.message : String(error) + return fail(`cannot reconstruct the goal before a continuation message: ${message}`) + } +} + +/** Recreate the live-shaped view consumed by the package's pure prompt renderer. */ +function goalView(folded: FoldedGoal, source: GoalMessageSource, fail: InvariantFailure): GoalView { + const goal = folded.goal + if (goal === undefined || folded.createdAt === undefined || folded.updatedAt === undefined + || goal.phase !== 'active' || goal.id !== source.goalId || goal.revision !== source.revision + || source.round !== folded.roundsStarted + 1 || source.round > goal.maxGoalRounds) { + return fail(`goal round ${source.round} cannot be reconstructed from the preceding durable goal state`) + } + return { + ...goal, + roundsStarted: folded.roundsStarted, + createdAt: folded.createdAt, + updatedAt: folded.updatedAt, + activation: 'armed', + } +} + +/** Validate one package-owned continuation message against its durable prefix. */ +function validateEvent( + prior: readonly SessionEvent[], + event: SessionEvent, + fail: InvariantFailure, +): void { + if (event.type !== 'user/message') return + const source = event.data.source + if (source.kind !== 'goal' || source.round <= 0) return + const expected = renderGoalRoundPrompt(goalView(foldChecked(prior, fail), source, fail), source.round) + if (!isDeepStrictEqual(event.data.content, expected)) { + fail(`goal round ${source.round} content does not match the package-owned continuation prompt`) + } +} + +/** Check existing sessions and every candidate event before Session publishes it. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) { + const prior: SessionEvent[] = [] + for (const event of session.events) { + validateEvent(prior, event, fail) + prior.push(event) + } + } + /* jscpd:ignore-start -- package companions share dispatch and registration plumbing */ + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + validateEvent(session.events, event, fail) + }, { global: true }) +}, { inject: ['sessions'] }) + +/** + * Register the goal-session 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/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts new file mode 100644 index 0000000000..19200747e8 --- /dev/null +++ b/packages/goal/goal-session/tests/invariant.spec.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { + GoalId, + renderGoalChange, + type GoalSnapshotChangeMeta, + type GoalView, +} from '@deepseek-ai/dsh-goal' +import * as GoalSessionInvariant from '@deepseek-ai/dsh-goal-session/invariant' +import { renderGoalRoundPrompt } from '@deepseek-ai/dsh-goal-session' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' + +const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: 1, + operation: 'create', + goal: { + id: GoalId('goal-session-invariant'), + revision: 1, + objective: 'verify every continuation prompt', + phase: 'active', + maxGoalRounds: 2, + }, + roundsStarted: 0, + createdAt: 1, + updatedAt: 1, +} + +const changeSource = { + kind: 'goal', + goalId: change.goal.id, + revision: change.goal.revision, + round: 0, +} as const + +function view(roundsStarted: number): GoalView { + return { ...change.goal, roundsStarted, createdAt: 1, updatedAt: 1, activation: 'armed' } +} + +function appendChange(session: Session): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + session.append('context/message', { + content: renderGoalChange(change), + source: changeSource, + meta: change as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) +} + +function appendRound(session: Session, turn: number, content = renderGoalRoundPrompt(view(turn - 2), turn - 1)): void { + const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: turn - 1 } as const + session.append('turn/start', { turn, trigger: { kind: 'message', source } }) + session.append('user/message', { content, source }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +async function mount(sessionFirst = false): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('goal-session-invariant')) + if (!sessionFirst) { + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(GoalSessionInvariant) + } + return { ctx, session } +} + +describe('goal-session prompt invariants', () => { + it('reconstructs existing rounds and accepts the next canonical prompt', async () => { + const { ctx, session } = await mount(true) + appendChange(session) + appendRound(session, 2) + + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(GoalSessionInvariant) + + expect(() => { appendRound(session, 3) }).not.toThrow() + ctx.sessions.create(SessionId('goal-session-invariant-dispatch')) + + const userSource = { kind: 'user' } as const + session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: userSource } }) + session.append('user/message', { + content: [{ type: 'text', text: 'ordinary human message' }], + source: userSource, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 4, reason: { kind: 'completed' } }) + + const stateSource = { ...changeSource, round: 0 } as const + session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: stateSource } }) + expect(() => { + session.append('user/message', { + content: [{ type: 'text', text: 'round zero is not a driver continuation' }], + source: stateSource, + }, { surfaceOp: 'append' }) + }).not.toThrow() + }) + + it('rejects a continuation whose content differs from the package renderer', async () => { + const { session } = await mount() + appendChange(session) + + expect(() => { + appendRound(session, 2, [{ type: 'text', text: 'counterfeit continuation' }]) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-goal-session', + })) + }) + + it('rejects a goal round without a reconstructable active goal', async () => { + const { session } = await mount() + const source = { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 } as const + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } }) + + expect(() => { + session.append('user/message', { + content: renderGoalRoundPrompt(view(0), 1), + source, + }, { surfaceOp: 'append' }) + }).toThrow(expect.objectContaining>({ + packageName: '@deepseek-ai/dsh-goal-session', + })) + }) + + it('attributes an invalid durable prefix during late loading', async () => { + const { ctx, session } = await mount(true) + session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + session.append('context/message', { + content: [{ type: 'text', text: 'counterfeit goal state' }], + source: changeSource, + meta: change as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + appendRound(session, 2) + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(GoalSessionInvariant)).rejects.toMatchObject({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-goal-session', + }) + }) +}) diff --git a/packages/goal/goal-session/tsconfig.json b/packages/goal/goal-session/tsconfig.json index d3cf5d5294..34ec1510e8 100644 --- a/packages/goal/goal-session/tsconfig.json +++ b/packages/goal/goal-session/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } 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/README.md b/packages/goal/goal/README.md index a71308d5f2..e50044d135 100644 --- a/packages/goal/goal/README.md +++ b/packages/goal/goal/README.md @@ -25,6 +25,8 @@ Injection may append immediately or wait in an active tool-batch FIFO. The servi Activation is never persisted. A fresh cache and every `agent/session-start` edge disarm it even when replay finds an active durable phase. A continuation driver also calls `disarm()` before unload or after durability uncertainty. Session resume, fork, and driver replacement therefore retain the objective, phase, revisions, and admitted-round count without initiating work; a later explicit resume mutation must arm continuation. +The separately published `./invariant` companion maintains an independent fold of each attached session. It rejects malformed goal metadata, source or model-visible content drift, discontinuous revisions, illegal lifecycle transitions, timestamp regressions, and non-sequential admitted rounds before the candidate event enters the durable log. + ## Extension points Policy plugins call the service verbs and react to the scoped `goal/changed` event. A continuation consumer admits rounds as `user/message` events with `GoalMessageSource`; ordinary human turns never increment `roundsStarted`. Consumers use the `Agent` interface and events rather than importing `dsh-agent-loop`. diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 534c258c6b..2427ccfa5e 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/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", @@ -35,6 +41,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-loader-smoke": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/goal/goal/src/invariant.ts b/packages/goal/goal/src/invariant.ts new file mode 100644 index 0000000000..42c83c65f0 --- /dev/null +++ b/packages/goal/goal/src/invariant.ts @@ -0,0 +1,79 @@ +/** Package-owned durable goal-stream invariants. @module @deepseek-ai/dsh-goal/invariant */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { applyGoalEvent, emptyGoalFoldState } from './fold.ts' +import type { GoalFoldState } from './fold.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-goal' + +/** Cordis companion plugin name. */ +export const name = 'goal-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** Copy the independent fold before validating one candidate event. */ +function cloneState(state: GoalFoldState): GoalFoldState { + return { + goal: state.goal, + roundsStarted: state.roundsStarted, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + lastRef: state.lastRef, + seenGoalIds: new Set(state.seenGoalIds), + } +} + +/** Apply one event through the strict goal decoder and attribute failures. */ +function applyChecked(state: GoalFoldState, event: SessionEvent, fail: InvariantFailure): void { + try { + applyGoalEvent(state, event) + } catch (error) { + /* v8 ignore next -- the strict goal decoder throws Error instances */ + const message = error instanceof Error ? error.message : String(error) + fail(`session event ${event.seq} violates the durable goal stream: ${message}`) + } +} + +/** Install an independent incremental fold over every attached session. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const states = new WeakMap() + const staged = new WeakMap() + + const seed = (session: Session): GoalFoldState => { + const state = emptyGoalFoldState() + for (const event of session.events) applyChecked(state, event, fail) + states.set(session, state) + return state + } + /* v8 ignore next -- session/event always follows list() or session/created seeding */ + const stateFor = (session: Session): GoalFoldState => states.get(session) ?? seed(session) + + for (const session of ctx.sessions.list()) seed(session) + ctx.on('session/created', (session) => { seed(session) }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + const state = cloneState(stateFor(session)) + applyChecked(state, event, fail) + staged.set(event, { session, state }) + }, { global: true }) + ctx.on('session/event', (session, event) => { + const candidate = staged.get(event) + /* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */ + if (candidate === undefined || candidate.session !== session) { + return fail('session/event reached publication without matching goal-fold validation') + } + staged.delete(event) + states.set(session, candidate.state) + }, { global: true }) +}, { inject: ['sessions'] }) + +/** + * Register the goal-stream 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/goal/goal/tests/invariant.spec.ts b/packages/goal/goal/tests/invariant.spec.ts new file mode 100644 index 0000000000..85f0b839d1 --- /dev/null +++ b/packages/goal/goal/tests/invariant.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { + GoalId, + renderGoalChange, + type GoalSnapshotChangeMeta, +} from '@deepseek-ai/dsh-goal' +import * as GoalInvariantCompanion from '@deepseek-ai/dsh-goal/invariant' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' + +const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: 1, + operation: 'create', + goal: { + id: GoalId('goal-invariant'), + revision: 1, + objective: 'check the stream', + phase: 'active', + maxGoalRounds: 2, + }, + roundsStarted: 0, + createdAt: 1, + updatedAt: 1, +} + +const changeSource = { + kind: 'goal', + goalId: change.goal.id, + revision: change.goal.revision, + round: 0, +} as const + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(GoalInvariantCompanion) + return ctx +} + +describe('goal stream invariants', () => { + it('accepts canonical goal snapshots and sequential admitted rounds', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('goal-invariant-valid')) + session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + session.append('context/message', { + content: renderGoalChange(change), + source: changeSource, + meta: change as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { + turn: 2, + trigger: { + kind: 'message', + source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 }, + }, + }) + expect(() => { + session.append('user/message', { + content: [{ type: 'text', text: 'continue' }], + source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 }, + }, { surfaceOp: 'append' }) + }).not.toThrow() + }) + + it('rejects model-visible drift before committing it and keeps the fold reusable', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('goal-invariant-invalid')) + session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + expect(() => { + session.append('context/message', { + content: [{ type: 'text', text: 'counterfeit' }], + source: changeSource, + meta: change as never, + }, { surfaceOp: 'append' }) + }).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-goal', + })) + expect(session.seq).toBe(1) + expect(() => { + session.append('context/message', { + content: renderGoalChange(change), + source: changeSource, + meta: change as never, + }, { surfaceOp: 'append' }) + }).not.toThrow() + }) + + it('reconstructs an existing durable goal before checking later rounds', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('goal-invariant-late-load')) + session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: changeSource } }) + session.append('context/message', { + content: renderGoalChange(change), + source: changeSource, + meta: change as never, + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(GoalInvariantCompanion) + session.append('turn/start', { + turn: 2, + trigger: { + kind: 'message', + source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 }, + }, + }) + expect(() => { + session.append('user/message', { + content: [{ type: 'text', text: 'continue after load' }], + source: { kind: 'goal', goalId: change.goal.id, revision: 1, round: 1 }, + }, { surfaceOp: 'append' }) + }).not.toThrow() + }) +}) diff --git a/packages/goal/goal/tsconfig.json b/packages/goal/goal/tsconfig.json index 8a97b2a5e0..a06b59ed0e 100644 --- a/packages/goal/goal/tsconfig.json +++ b/packages/goal/goal/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../core/agent" + }, + { + "path": "../../support/invariants" } ] } 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/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index b9b4912cf4..3fcc0c61a9 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/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-goal": "^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,6 +43,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "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/goal/tool-goal/src/invariant.ts b/packages/goal/tool-goal/src/invariant.ts new file mode 100644 index 0000000000..d3ea60f049 --- /dev/null +++ b/packages/goal/tool-goal/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-goal`. + * @module @deepseek-ai/dsh-tool-goal/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-goal' + +/** Cordis companion plugin name. */ +export const name = 'tool-goal-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this model-facing adapter owns no independent state or event protocol; + * accepted mutations are checked by the goal domain and authority behavior is package-tested. + */ +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/goal/tool-goal/tsconfig.json b/packages/goal/tool-goal/tsconfig.json index d5c34d4e29..f3026b0aff 100644 --- a/packages/goal/tool-goal/tsconfig.json +++ b/packages/goal/tool-goal/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../goal" + }, + { + "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..5d8544b9aa --- /dev/null +++ b/packages/guard/repeat-tool-guard/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..6972d109da --- /dev/null +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -0,0 +1,101 @@ +/** Package-owned hook provenance-stream invariants. @module @deepseek-ai/dsh-hook-protocol/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +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. */ +// 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() + 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'] }) +/* jscpd:ignore-end */ + +/** + * 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)) 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/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..18bc942e9f --- /dev/null +++ b/packages/hooks/hooks-claude/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 870d369784..2525c64421 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) }) @@ -689,7 +694,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..5f0f6ed173 --- /dev/null +++ b/packages/hooks/hooks-codex/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/README.md b/packages/llm/README.md index 405c2f18f4..0c937c17dc 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -10,4 +10,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, retry policy, and 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, retry policy, 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 route-owning adapter optionally resolves exact provider/model context capacity; the token meter remains model-agnostic. A new provider adapter registers one or more provider routes on `ctx.llm` without touching the interface or consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for 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 d4fa19af3d..ad7916f918 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -20,11 +20,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/package.json b/packages/llm/llm-deepseek/package.json index 02e8cb85ac..4946c2bcd9 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", "@deepseek-ai/dsh-timeout": "^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-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 74adafb225..237751fc39 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, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } 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 { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' @@ -22,6 +28,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. */ @@ -111,6 +119,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 consumer = new AbortController() const upstream = options.signal === undefined diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index c0d0df0f08..ed374f6ecc 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -21,8 +21,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 }, ] /** @@ -50,6 +50,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({ @@ -72,12 +73,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/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts new file mode 100644 index 0000000000..dd2df6e99c --- /dev/null +++ b/packages/llm/llm-deepseek/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 0b2dae67bf..ffceaacd2d 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -517,6 +517,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 () => { @@ -536,14 +538,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 () => { @@ -560,6 +571,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() @@ -572,6 +585,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-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 5e427d88b9..45c2af21a5 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../support/invariants" + }, { "path": "../../util/timeout" } diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index bedfc5517d..8a6736f112 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`, and `streamIdleTimeoutMs`. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 2a5aed1d76..06b4cbeb3a 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", "@deepseek-ai/dsh-timeout": "^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-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 25240109bb..7d91e9c351 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 { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolveProfiles } from './config.ts' import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' @@ -89,6 +89,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/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts new file mode 100644 index 0000000000..a096804fd2 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 1b7445fa56..7b28cdc1b5 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -322,6 +322,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 () => { @@ -373,6 +376,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-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 5e427d88b9..45c2af21a5 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../support/invariants" + }, { "path": "../../util/timeout" } diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index f84fdba09a..699e7e3dad 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 non-negative bounded timer delay. Full jitter may schedule zero milliseconds at its lower boundary. + ```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..784f459606 --- /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 0..${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..81b1aca16d --- /dev/null +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -0,0 +1,148 @@ +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, + }) + 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() + }) + + 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: -1 }, /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/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/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/llm/llm/README.md b/packages/llm/llm/README.md index a5ccd42b43..e92527d7c8 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; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates 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, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead. ### Content-block vocabulary (`types.ts`) 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/index.ts b/packages/llm/llm/src/index.ts index fb431a308c..ce8b85f08c 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -7,7 +7,15 @@ */ import { Context, Service } from 'cordis' -import type { GenerateOptions, LlmFailure, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts' +import type { + GenerateOptions, + LlmFailure, + LlmModelContext, + LlmModelInfo, + LlmProviderInfo, + Message, + StreamChunk, +} from './types.ts' import type { ProviderRequestId } from './brand.ts' import { deepFreeze } from './call-config.ts' import { HarnessError } from './error.ts' @@ -122,6 +130,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`. @@ -217,6 +239,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/invariant.ts b/packages/llm/llm/src/invariant.ts new file mode 100644 index 0000000000..76d55509cb --- /dev/null +++ b/packages/llm/llm/src/invariant.ts @@ -0,0 +1,95 @@ +/** Package-owned LLM stream-protocol invariants. @module @deepseek-ai/dsh-llm/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** 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) => { + ctx.on('llm/stream', (_options, next) => validateStream(next(), fail), { global: true, prepend: true }) +} + +/** + * Register the LLM 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/src/types.ts b/packages/llm/llm/src/types.ts index bbe2e09b59..0cb8fa6935 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -155,6 +155,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/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 9f90a2b7cd..90be1ffcb0 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -13,7 +13,7 @@ import LlmService, { ProviderRequestId, 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[]) { @@ -48,6 +48,7 @@ class CatalogAdapter extends ScriptedAdapter { constructor( private readonly provider: LlmProviderInfo, private readonly models: readonly LlmModelInfo[], + private readonly contexts: Readonly> = {}, ) { super(SCRIPT) } @@ -59,11 +60,19 @@ 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[] = [ { 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' } }, ] @@ -644,8 +653,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'], @@ -694,13 +737,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/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/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/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/index.ts b/packages/llm/token-meter/src/index.ts index 0c18dee74b..e17b0c06b6 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,15 @@ 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 + // 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() 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/invariant.ts b/packages/llm/token-meter/src/invariant.ts new file mode 100644 index 0000000000..b8f0cc385a --- /dev/null +++ b/packages/llm/token-meter/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/src/types.ts b/packages/llm/token-meter/src/types.ts index 7fb35af997..255425b639 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 = 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 e7ddca6696..e3618474e2 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,29 +87,18 @@ 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('exposes an empty public configuration type', () => { + expectTypeOf<{}>().toExtend() + expectTypeOf<{ contextWindow: number }>().not.toExtend() }) - 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]: {} } as unknown as TokenMeterConfig)) + .toThrow(`TokenMeterConfig: unknown key "${key}"`) + }, + ) it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => { const ctx = new Context() @@ -123,7 +112,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 +320,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' }) @@ -631,19 +620,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/lsp/README.md b/packages/lsp/README.md new file mode 100644 index 0000000000..147888a259 --- /dev/null +++ b/packages/lsp/README.md @@ -0,0 +1,13 @@ +# lsp/ - LSP capability family + +The language-server capability seam: an abstract LSP interface, a generic stdio provider, and the model-facing `lsp` tool. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` | +| `lsp-local/` | Generic multi-server local backend (spawn, JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) | +| `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) | + +The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation. + +See the [LSP capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the local host reads through Node APIs rather than `ctx.fs`, and why extension ownership is exclusive within one runtime. diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md new file mode 100644 index 0000000000..85cc7945dd --- /dev/null +++ b/packages/lsp/lsp-local/README.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-lsp-local + +A **generic local stdio language-server backend** for `ctx.lsp`. One plugin instance accepts a named server table and registers one isolated provider per entry. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in `cordis.yml` overlays. + +Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). + +## What it does + +- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. +- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. +- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. +- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. +- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. + +## Configuration + +The `servers` record key is the stable provider id reserved on `ctx.lsp`; each value has this shape: + +| Server key | Default | Meaning | +|---|---|---| +| `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. | +| `args` | `[]` | Arguments passed to the executable. | +| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). | +| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). | +| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. | +| `configuration` | `null` | Static answer to every `workspace/configuration` item. | +| `maxMessageBytes` | `16000000` | Largest single framed message accepted from the server. | +| `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. | +| `maxDocumentBytes` | `4000000` | Largest source file this host will open. | +| `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. | +| `killGraceMs` | `2000` | Grace for request cancellation and for SIGTERM→SIGKILL escalation. | + +`servers` must contain at least one entry, and every id must be non-empty. Timer budgets must be positive integers no greater than Node's `2_147_483_647` ms timer limit. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query. + +## Protocol behavior + +Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes valid `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line. Missing results, malformed ranges or positions, and malformed hover encodings fail as structured `LSP_MALFORMED_RESPONSE` errors. + +## Security boundary + +The provider trusts its configured server and claims no sandbox confinement. It canonicalizes and reads source through Node APIs, rejecting a source that is missing, non-regular, non-UTF-8, oversized, or whose canonical path resolves outside the canonical workspace (symlink aliases share one instance). Result locations may be external, but an external path cannot become a query source. The first implementation therefore requires trusted host-local deployment; restricted, remote, or virtual workspaces require another provider. + +## Model Experience + +Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized results; this host contributes no prompt or schema itself. + +#### KV Cache effect + +No direct invalidation; `dsh-tool-lsp` owns request-prefix changes. + +## Known Limitations and Deferred Work + +- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW | O_NONBLOCK` (final-component symlink guard plus nonblocking rejection of FIFOs) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks. +- **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. +- **Per-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal. diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json new file mode 100644 index 0000000000..6815b1c917 --- /dev/null +++ b/packages/lsp/lsp-local/package.json @@ -0,0 +1,50 @@ +{ + "name": "@deepseek-ai/dsh-lsp-local", + "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "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-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-lsp": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "cordis": "^4.0.0-rc.7", + "typescript": "^6.0.3", + "typescript-language-server": "^5.0.0" + } +} diff --git a/packages/lsp/lsp-local/src/abort.ts b/packages/lsp/lsp-local/src/abort.ts new file mode 100644 index 0000000000..7790069e44 --- /dev/null +++ b/packages/lsp/lsp-local/src/abort.ts @@ -0,0 +1,48 @@ +/** + * Shared cancellation helpers for the local LSP provider's host-I/O, queue, and protocol phases. + * @module @deepseek-ai/dsh-lsp-local/abort + */ + +import { timeoutOf } from '@deepseek-ai/dsh-timeout' + +/** + * Build an abort Error carrying the signal's reason and preserving timeout classification. + * @param signal - the aborted signal whose reason to surface. + * @returns the timeout reason if present, else the Error reason, else a generic aborted Error. + */ +export function abortError(signal: AbortSignal): Error { + const timeout = timeoutOf(signal) + if (timeout !== undefined) return timeout + const reason: unknown = signal.reason + if (reason instanceof Error) return reason + return new Error('LSP query aborted') +} + +/** + * Throw the signal's classified abort error when it has already fired. + * @param signal - the optional query cancellation signal. + */ +export function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw abortError(signal) +} + +/** + * Await work while allowing a query signal to abandon its wait; the underlying work keeps its own + * handlers and continues to its owner-defined quiescence boundary. + * @param work - the owned asynchronous work. + * @param signal - optional query cancellation. + * @returns the work result, or a rejection carrying the classified abort reason. + */ +export function abortable(work: Promise, signal?: AbortSignal): Promise { + if (signal === undefined) return work + if (signal.aborted) return Promise.reject(abortError(signal)) + const canceled = Promise.withResolvers() + const onAbort = (): void => { canceled.reject(abortError(signal)) } + signal.addEventListener('abort', onAbort, { once: true }) + const normalized = work.catch((error: unknown) => { + /* v8 ignore next -- owned LSP promises reject with Error; coercion defends the generic helper. */ + throw error instanceof Error ? error : new Error(String(error)) + }) + return Promise.race([normalized, canceled.promise]) + .finally(() => { signal.removeEventListener('abort', onAbort) }) +} diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts new file mode 100644 index 0000000000..ae725b56f7 --- /dev/null +++ b/packages/lsp/lsp-local/src/connection.ts @@ -0,0 +1,331 @@ +/** + * A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound + * requests/notifications, and inbound server→client requests: it answers `workspace/configuration` + * from static config, and rejects `workspace/applyEdit` (this host never applies edits or runs + * commands). It caps stderr, surfaces framing/decoder failures as a fatal close, and exposes the + * child handle so the instance owns process-signal teardown. + * @module @deepseek-ai/dsh-lsp-local/connection + */ + +import type { ChildProcessByStdio } from 'node:child_process' +import { spawn } from 'node:child_process' +import type { Readable, Writable } from 'node:stream' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { encodeMessage, MessageDecoder } from './framing.ts' + +/** How to launch the server and answer its config requests. */ +export interface ConnectionSpec { + /** The resolved absolute executable path (no shell). */ + readonly command: string + /** Arguments passed to the executable. */ + readonly args: readonly string[] + /** The child's working directory (the canonical workspace). */ + readonly cwd: string + /** The child's environment (credential-scrubbed, with overrides applied). */ + readonly env: Record + /** Largest single framed message accepted from the server. */ + readonly maxMessageBytes: number + /** Largest stderr tail retained for diagnostics. */ + readonly maxStderrBytes: number + /** Static answer to every `workspace/configuration` item. */ + readonly configuration: unknown +} + +interface Pending { + resolve: (value: unknown) => void + reject: (error: Error) => void +} + +/** A live JSON-RPC endpoint bound to one child process. */ +export class LspConnection { + private readonly child: ChildProcessByStdio + private readonly decoder: MessageDecoder + private readonly pending = new Map() + private nextId = 1 + private stderr = Buffer.alloc(0) + private closeReason: Error | undefined + /** Set once the process has fully exited; the instance awaits it during teardown. */ + readonly closed: Promise + + /** + * @param spec - how to launch the server and answer its config requests. + * @param onServerRequest - answers a server→client request; rejects to send an error response. + */ + constructor( + private readonly spec: ConnectionSpec, + private readonly onServerRequest: (method: string, params: unknown) => Promise, + ) { + this.decoder = new MessageDecoder(spec.maxMessageBytes) + // `detached` puts the server in its own process group so teardown can signal the WHOLE group + // (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver). + this.child = spawn(spec.command, [...spec.args], { + cwd: spec.cwd, + env: spec.env, + stdio: ['pipe', 'pipe', 'pipe'], + detached: true, + }) + this.closed = new Promise((resolve) => { + this.child.on('close', () => { + const reason = this.closeReason ?? new Error(this.exitMessage()) + // Record the reason so any request issued AFTER close rejects immediately instead of hanging + // (a closed process sends no further responses). + this.closeReason = reason + this.failAll(reason) + resolve() + }) + }) + this.child.on('error', (error) => { this.fail(error) }) + // Child stdin can fail while the process itself remains alive (for example, a server closes fd + // 0). Treat that as a fatal connection error so pending requests reject immediately instead of + // waiting for a process-close event that may never arrive. + this.child.stdin.on('error', (error) => { this.fail(error) }) + this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) + this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) }) + } + + /** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */ + get pid(): number { + /* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */ + return this.child.pid ?? -1 + } + + /** The retained stderr tail, for diagnostics on a failed server. */ + get stderrTail(): string { + return this.stderr.toString('utf8') + } + + /** + * Send a request and await its result. + * @param method - the JSON-RPC method. + * @param params - the request params. + * @returns the response result; rejects on an error response, write failure, or close. + */ + request(method: string, params: unknown): Promise { + const id = this.nextId++ + const promise = new Promise((resolve, reject) => { + if (this.closeReason !== undefined) { + reject(this.closeReason) + return + } + this.pending.set(id, { resolve, reject }) + // `write()` records either synchronous or callback-delivered failures on the connection and + // rejects every pending request. This handler only consumes the write promise itself. + void this.write({ jsonrpc: '2.0', id, method, params }).catch(() => {}) + }) + // A caller that stops awaiting (e.g. an aborted query) can leave this promise to reject later + // when the process closes; a benign no-op handler keeps that from surfacing as an unhandled + // rejection. The returned promise still delivers the rejection to the caller's own await/catch. + promise.catch(() => {}) + return promise + } + + /** + * Send a notification (no id, no response). + * @param method - the JSON-RPC method. + * @param params - the notification params. + * @returns a promise that settles when the framed notification has been written. + */ + notify(method: string, params: unknown): Promise { + return this.write({ jsonrpc: '2.0', method, params }) + } + + /** + * Send a `$/cancelRequest` for an in-flight request id (best-effort; ignores write failure). + * @param requestId - the numeric id of the request to cancel. + */ + cancel(requestId: number): void { + // The server is already gone or unwritable when this rejects; `write()` has recorded the fatal + // connection failure and rejected the pending request, so cancellation remains best-effort. + void this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } }).catch(() => {}) + } + + /** + * The id the NEXT `request()` will use, so the instance can pre-arm a cancel. + * @returns the numeric id the next request will be assigned. + */ + peekNextId(): number { + return this.nextId + } + + /** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */ + terminate(): void { + this.signalGroup('SIGTERM') + } + + /** Send SIGKILL to the server's process group. */ + kill(): void { + this.signalGroup('SIGKILL') + } + + /** + * Wait until the owned process group has no members. + * @param signal - optional bound for the wait. + * @returns `true` when the group exited, or `false` when the signal aborted first. + */ + async waitForProcessGroupExit(signal?: AbortSignal): Promise { + while (this.processGroupAlive()) { + if (signal?.aborted) return false + await yieldToEventLoop() + } + return true + } + + /** + * Signal the whole process group (negative pid) so helper processes are reached; fall back to the + * direct child if the group send fails. Never throws — teardown races process exit. + */ + private signalGroup(sig: NodeJS.Signals): void { + const pid = this.child.pid + if (pid === undefined) return + try { + process.kill(-pid, sig) + } catch { + // The group is gone (already exited) or could not be signalled; try the direct child. + try { + this.child.kill(sig) + } catch { + // Already dead; nothing to signal. + } + } + } + + /** Whether the detached process group still has at least one member. */ + private processGroupAlive(): boolean { + const pid = this.child.pid + /* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */ + if (pid === undefined) return false + try { + process.kill(-pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + /* v8 ignore next -- POSIX reports an absent group as ESRCH, but child-reaping timing makes + whether lifecycle tests observe this branch platform-dependent. */ + if (code === 'ESRCH') return false + /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs + process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */ + if (code === 'EPERM') return true + return this.child.exitCode === null && this.child.signalCode === null + /* v8 ignore stop */ + } + } + + private onStdout(chunk: Buffer): void { + let messages: unknown[] + try { + messages = this.decoder.push(chunk) + } catch (error) { + // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and + // SIGKILL the whole group so helper processes don't outlive the leader. + this.fail(asError(error)) + this.signalGroup('SIGKILL') + return + } + for (const message of messages) this.dispatch(message) + } + + private onStderr(chunk: Buffer): void { + // Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just + // before it exits, so the final bounded segment is the useful one. + const cap = this.spec.maxStderrBytes + if (chunk.length >= cap) { + // Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer. + this.stderr = Buffer.from(chunk.subarray(chunk.length - cap)) + return + } + const retainedBytes = Math.min(this.stderr.length, cap - chunk.length) + this.stderr = Buffer.concat([ + this.stderr.subarray(this.stderr.length - retainedBytes), + chunk, + ], retainedBytes + chunk.length) + } + + private dispatch(message: unknown): void { + if (message === null || typeof message !== 'object') return + const frame = message as Record + const id = frame.id + const method = frame.method + if (typeof method === 'string' && (typeof id === 'number' || typeof id === 'string')) { + // A response-write failure has already invalidated the connection in `write()`. + /* v8 ignore next -- protocol tests exercise response writes; only a simultaneous connection + failure makes this consumption handler run. */ + void this.handleServerRequest(id, method, frame.params).catch(() => {}) + return + } + if (typeof method === 'string') { + // A server→client notification (e.g. diagnostics, logs): ignored by this MVP host. + return + } + if (typeof id === 'number') this.handleResponse(id, frame) + } + + private async handleServerRequest(id: number | string, method: string, params: unknown): Promise { + try { + const result = await this.onServerRequest(method, params) + await this.write({ jsonrpc: '2.0', id, result }) + } catch (error) { + await this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } }) + } + } + + private handleResponse(id: number, frame: Record): void { + const pending = this.pending.get(id) + if (!pending) return + this.pending.delete(id) + const error = frame.error + if (error !== null && typeof error === 'object') { + const record = error as Record + pending.reject(new Error(typeof record.message === 'string' ? record.message : 'LSP error response')) + return + } + pending.resolve(frame.result) + } + + private write(message: unknown): Promise { + if (this.closeReason !== undefined) return Promise.reject(this.closeReason) + return new Promise((resolve, reject) => { + const done = (error?: Error | null): void => { + if (error === undefined || error === null) { + resolve() + return + } + this.fail(error) + reject(error) + } + try { + this.child.stdin.write(encodeMessage(message), done) + /* v8 ignore start -- Node stream write failures are callback-delivered; this guards a + nonconforming Writable implementation throwing synchronously. */ + } catch (error) { + const failure = asError(error) + this.fail(failure) + reject(failure) + } + /* v8 ignore stop */ + }) + } + + /** The exit-close error message, appending the retained stderr tail when the server wrote any. */ + private exitMessage(): string { + const tail = this.stderrTail.trim() + return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}` + } + + private fail(error: Error): void { + /* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */ + if (this.closeReason === undefined) this.closeReason = error + this.failAll(error) + } + + private failAll(error: Error): void { + const waiting = [...this.pending.values()] + this.pending.clear() + for (const pending of waiting) pending.reject(error) + } +} + +/** Coerce an unknown thrown value to an `Error`. */ +function asError(value: unknown): Error { + /* v8 ignore next -- the non-Error branch guards against a non-Error throw, which our paths never produce. */ + return value instanceof Error ? value : new Error(String(value)) +} diff --git a/packages/lsp/lsp-local/src/framing.ts b/packages/lsp/lsp-local/src/framing.ts new file mode 100644 index 0000000000..bfa6b362b5 --- /dev/null +++ b/packages/lsp/lsp-local/src/framing.ts @@ -0,0 +1,102 @@ +/** + * LSP base-protocol framing: `Content-Length`-delimited JSON-RPC over a byte stream. The encoder + * produces one framed buffer; the decoder buffers incoming bytes and yields complete message bodies, + * bounding the header and total message size so a hostile or broken server cannot exhaust memory. + * @module @deepseek-ai/dsh-lsp-local/framing + */ + +/** The header/body separator in the LSP base protocol. */ +const HEADER_SEPARATOR = '\r\n\r\n' + +/** Cap on the header section so a server that never sends the separator cannot grow the buffer forever. */ +const MAX_HEADER_BYTES = 1 << 16 + +/** + * Encode one JSON-RPC message as a framed LSP buffer (`Content-Length: N\r\n\r\n`). + * @param message - the JSON-RPC message object to serialize. + * @returns the framed bytes ready to write to the server's stdin. + */ +export function encodeMessage(message: unknown): Buffer { + const body = Buffer.from(JSON.stringify(message), 'utf8') + const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii') + return Buffer.concat([header, body]) +} + +/** + * A streaming decoder for `Content-Length`-framed JSON-RPC. Feed it stdout chunks; it returns any + * whole message bodies that completed. It parses only the `Content-Length` header and ignores other + * headers (e.g. `Content-Type`), matching the base protocol. + */ +export class MessageDecoder { + private buffer: Buffer = Buffer.alloc(0) + private readonly maxMessageBytes: number + + /** + * @param maxMessageBytes - reject any single framed body larger than this (guards memory). + */ + constructor(maxMessageBytes: number) { + this.maxMessageBytes = maxMessageBytes + } + + /** + * Append a chunk and return every message body that is now complete. + * @param chunk - raw bytes from the server's stdout. + * @returns the parsed JSON bodies, in arrival order (possibly empty). + * @throws Error when a header is malformed or a body exceeds `maxMessageBytes`. + */ + push(chunk: Buffer): unknown[] { + this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]) + const messages: unknown[] = [] + for (;;) { + const step = this.next() + if (!step.ready) break + messages.push(step.message) + } + return messages + } + + /** Parse and consume the next complete message, or report that more bytes are needed. */ + private next(): { ready: false } | { ready: true; message: unknown } { + const separator = this.buffer.indexOf(HEADER_SEPARATOR) + if (separator < 0) { + if (this.buffer.length > MAX_HEADER_BYTES) { + throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`) + } + return { ready: false } + } + if (separator > MAX_HEADER_BYTES) { + throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`) + } + const headerText = this.buffer.toString('ascii', 0, separator) + const contentLength = parseContentLength(headerText) + if (contentLength > this.maxMessageBytes) { + throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`) + } + const bodyStart = separator + HEADER_SEPARATOR.length + const bodyEnd = bodyStart + contentLength + if (this.buffer.length < bodyEnd) return { ready: false } + const body = this.buffer.toString('utf8', bodyStart, bodyEnd) + this.buffer = this.buffer.subarray(bodyEnd) + try { + return { ready: true, message: JSON.parse(body) } + } catch (error) { + /* v8 ignore next -- JSON.parse throws a SyntaxError (an Error); the String() fallback is defensive. */ + throw new Error(`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`) + } + } +} + +/** Read the `Content-Length` header value (case-insensitive), rejecting a missing or non-numeric one. */ +function parseContentLength(headerText: string): number { + for (const line of headerText.split('\r\n')) { + const colon = line.indexOf(':') + if (colon < 0) continue + if (line.slice(0, colon).trim().toLowerCase() !== 'content-length') continue + const value = Number(line.slice(colon + 1).trim()) + if (!Number.isInteger(value) || value < 0) { + throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`) + } + return value + } + throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`) +} diff --git a/packages/lsp/lsp-local/src/host.ts b/packages/lsp/lsp-local/src/host.ts new file mode 100644 index 0000000000..11996908ac --- /dev/null +++ b/packages/lsp/lsp-local/src/host.ts @@ -0,0 +1,154 @@ +/** + * Host-filesystem source access for the local provider, using Node APIs directly in the + * subprocess's namespace (never `ctx.fs`): only the LSP result is model-visible, so a query does not + * satisfy read-before-write policy and emits no `fs/observed`. Canonicalization derives target + * identity from `realpath`, so symlink aliases share a workspace; a source is rejected before server + * startup when it is missing, non-regular, non-UTF-8, oversized, or canonically outside the + * workspace. External result locations are allowed, but an external path can never become a query + * source. + * @module @deepseek-ai/dsh-lsp-local/host + */ + +import { constants } from 'node:fs' +import { open, realpath, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { isAbsolute, resolve as resolvePath, sep } from 'node:path' +import { throwIfAborted } from './abort.ts' + +/** A validated source: its canonical absolute path and current UTF-8 text. */ +export interface HostSource { + /** The canonical (realpath-resolved) absolute path, inside the canonical workspace. */ + readonly canonicalPath: string + /** The file's current text, read as UTF-8. */ + readonly text: string +} + +/** + * Canonicalize a workspace root: it must exist and be a directory. The returned realpath supplies + * process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots + * collapse to one instance. + * @param workspaceRoot - the caller's workspace root (absolute). + * @param signal - optional cancellation observed around each filesystem operation. + * @returns the canonical directory path. + * @throws Error when the path is missing or not a directory. + */ +export async function canonicalizeWorkspace(workspaceRoot: string, signal?: AbortSignal): Promise { + throwIfAborted(signal) + let canonical: string + try { + canonical = await realpath(workspaceRoot) + } catch (error) { + throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`) + } + throwIfAborted(signal) + const info = await stat(canonical) + throwIfAborted(signal) + if (!info.isDirectory()) { + throw new Error(`workspace root "${workspaceRoot}" is not a directory`) + } + return canonical +} + +/** + * Resolve, canonicalize, validate, and read a query source in one pass. A relative `filePath` + * resolves against `canonicalWorkspace`; an absolute one is taken directly. The canonical target + * must be a regular UTF-8 file no larger than `maxDocumentBytes`, and must lie inside the canonical + * workspace. + * @param filePath - the model-supplied source path (relative or absolute). + * @param canonicalWorkspace - the already-canonicalized workspace root. + * @param maxDocumentBytes - the largest source this host will open. + * @param signal - optional cancellation observed throughout resolution, validation, and reading. + * @returns the canonical path and current UTF-8 text. + * @throws Error when the source is missing, non-regular, oversized, non-UTF-8, or out of workspace. + */ +export async function readHostSource( + filePath: string, + canonicalWorkspace: string, + maxDocumentBytes: number, + signal?: AbortSignal, +): Promise { + throwIfAborted(signal) + const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath) + let canonicalPath: string + try { + canonicalPath = await realpath(requested) + } catch (error) { + throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`) + } + throwIfAborted(signal) + if (!isInside(canonicalWorkspace, canonicalPath)) { + throw new Error(`source "${filePath}" resolves outside the workspace`) + } + // Open ONE handle after containment, then stat and read through it: a concurrent replace between + // realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we + // actually read (no path-based TOCTOU). O_NOFOLLOW rejects the final component being swapped for a + // symlink between realpath and open (which would otherwise escape the workspace). + // O_NONBLOCK prevents a FIFO with no writer from hanging before fstat can reject it as nonregular. + const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) + try { + throwIfAborted(signal) + const info = await handle.stat() + throwIfAborted(signal) + if (!info.isFile()) { + throw new Error(`source "${filePath}" is not a regular file`) + } + if (info.size > maxDocumentBytes) { + throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`) + } + // Bound the read to the cap even if the file grew after stat: read one extra byte and reject on + // overflow, so a concurrent grow cannot defeat the memory bound. + const buffer = await readCapped(handle, maxDocumentBytes, filePath, signal) + const text = decodeUtf8Strict(buffer, filePath) + throwIfAborted(signal) + return { canonicalPath, text } + } finally { + await handle.close() + } +} + +/** Read at most `maxBytes` from the handle, rejecting when the source overflows the cap. */ +async function readCapped( + handle: FileHandle, + maxBytes: number, + filePath: string, + signal?: AbortSignal, +): Promise { + const limit = maxBytes + 1 + const chunk = Buffer.allocUnsafe(limit) + let total = 0 + for (;;) { + throwIfAborted(signal) + const { bytesRead } = await handle.read(chunk, total, limit - total, total) + throwIfAborted(signal) + if (bytesRead === 0) break + total += bytesRead + /* v8 ignore next 3 -- overflow requires the file to grow past the cap between stat and read (a concurrent mutation); defensive. */ + if (total > maxBytes) { + throw new Error(`source "${filePath}" grew past the ${maxBytes}-byte limit while reading`) + } + } + return chunk.subarray(0, total) +} + +/** Whether `child` is the workspace itself or a descendant of it (both already canonical). */ +function isInside(workspace: string, child: string): boolean { + if (child === workspace) return true + /* v8 ignore next -- a canonical non-root workspace never ends with a separator; the guard covers the filesystem root. */ + const base = workspace.endsWith(sep) ? workspace : workspace + sep + return child.startsWith(base) +} + +/** Decode strictly as UTF-8: a fatal decoder rejects only malformed bytes, keeping a legitimate U+FFFD. */ +function decodeUtf8Strict(buffer: Buffer, filePath: string): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buffer) + } catch { + throw new Error(`source "${filePath}" is not valid UTF-8 text`) + } +} + +/** Extract a message from an unknown thrown value without leaking `any`. */ +function messageOf(error: unknown): string { + /* v8 ignore next -- Node fs rejections are always Error instances; the String() fallback is defensive. */ + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts new file mode 100644 index 0000000000..095d761624 --- /dev/null +++ b/packages/lsp/lsp-local/src/index.ts @@ -0,0 +1,336 @@ +/** + * Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table + * of server commands and registers one isolated provider for each entry. Every provider lazily + * single-flights one server process per canonical workspace realpath, serves transient-open queries + * through it, and evicts a crashed process so a later query can replace it. Providers read sources + * through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no + * sandbox confinement. + * + * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal + * unregisters from `ctx.lsp` and tears down every live server. + * @module @deepseek-ai/dsh-lsp-local + */ + +import { accessSync, constants, statSync } from 'node:fs' +import { delimiter, isAbsolute, join } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' +import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp' +import type { + LspProvider, + LspProviderQuery, + LspQueryResult, +} from '@deepseek-ai/dsh-lsp' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { abortable, abortError } from './abort.ts' +import { canonicalizeWorkspace, readHostSource } from './host.ts' +import { LspInstance } from './instance.ts' +import type { InstanceSpec } from './instance.ts' + +export { canonicalizeWorkspace, readHostSource } from './host.ts' +export { encodeMessage, MessageDecoder } from './framing.ts' +export { + negotiatePositionEncoding, + normalizeHover, + normalizeLocations, + requestMethod, + supportsOperation, + supportsTransientOpen, +} from './translate.ts' +export { LspInstance } from './instance.ts' +export { LspConnection } from './connection.ts' + +/** Cordis plugin name for loader diagnostics. */ +export const name = 'lsp-local' + +/** Services required by this plugin. */ +export const inject = ['lsp'] + +/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */ +const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + +const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000 +const DEFAULT_MAX_STDERR_BYTES = 1_000_000 +const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000 +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000 +const DEFAULT_KILL_GRACE_MS = 2_000 + +/** One configured local language server and its host bounds. */ +export interface LspLocalServerConfig { + /** Executable to spawn (absolute, or resolved on PATH at load). */ + command: string + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + extensionToLanguage: Record + /** Arguments passed to the executable (no shell). Default `[]`. */ + args?: string[] + /** Extra env vars merged on top of the scrubbed ambient env. Default `{}`. */ + env?: Record + /** Static `initialize` options forwarded to the server. Default `null`. */ + initializationOptions?: unknown + /** Static answer to every `workspace/configuration` item. Default `null`. */ + configuration?: unknown + /** Largest single framed message accepted from the server (bytes). Default 16000000. */ + maxMessageBytes?: number + /** Largest stderr tail retained for diagnostics (bytes). Default 1000000. */ + maxStderrBytes?: number + /** Largest source file this host will open (bytes). Default 4000000. */ + maxDocumentBytes?: number + /** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */ + shutdownTimeoutMs?: number + /** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */ + killGraceMs?: number +} + +/** Plugin configuration: provider id → local language-server configuration. */ +export interface Config { + /** Non-empty table of stable provider ids to independent local server configurations. */ + servers: Record +} + +/** One server config after schemastery fills every default. */ +type ResolvedServerConfig = Required + +const LspLocalServerConfig: z = z.object({ + command: z.string().required(), + args: z.array(String).default([]), + env: z.dict(String).default({}), + extensionToLanguage: z.dict(String).required(), + initializationOptions: z.any().default(null), + configuration: z.any().default(null), + maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES), + maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES), + maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES), + shutdownTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SHUTDOWN_TIMEOUT_MS), + killGraceMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_KILL_GRACE_MS), +}) + +export const Config: z = z.object({ + servers: z.dict(LspLocalServerConfig).required(), +}) + +/** + * Register the configured stdio LSP providers. Resolves every executable at load (after credential + * scrubbing) before publishing any provider; each process launches lazily on its first matching + * query. + * @param ctx - the plugin context (must inject `lsp`). + * @param config - the resolved plugin configuration (schemastery has filled every default). + */ +export function apply(ctx: Context, config: Config): void { + const entries = Object.entries(config.servers) + if (entries.length === 0) throw new Error('lsp-local: servers must contain at least one server') + + // Resolve every server-local setting before registration so a bad later command or bound cannot + // publish an earlier provider. Registry-level mapping conflicts are rolled back below. + const providers = entries.map(([providerId, rawConfig]) => { + if (providerId.trim() === '') throw new Error('lsp-local: server ids must be non-empty strings') + const resolved = rawConfig as ResolvedServerConfig + validateServerConfig(providerId, resolved) + const childEnv = buildChildEnv(resolved.env) + const executable = resolveExecutable(resolved.command, childEnv) + return new LocalLspProvider(providerId, resolved, childEnv, executable) + }) + + ctx.effect(() => { + const disposers: Array<() => void> = [] + try { + for (const provider of providers) disposers.push(ctx.lsp.registerProvider(provider)) + } catch (error) { + for (const dispose of disposers.reverse()) dispose() + throw error + } + return async () => { + // Remove every route before process teardown so no new query can enter a draining provider. + for (const dispose of disposers.reverse()) dispose() + await Promise.all(providers.map(provider => provider.disposeAll())) + } + }, 'lsp-local.registerProviders') +} + +/** Validate one resolved server entry before any provider in the table is registered. */ +function validateServerConfig(providerId: string, resolved: ResolvedServerConfig): void { + // Teardown budgets feed `deadline()`, whose `<= 0` is the internal no-timeout sentinel; a + // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. + assertTimer(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs) + assertTimer(providerId, 'killGraceMs', resolved.killGraceMs) + // Byte caps must be positive: a nonpositive stderr cap defeats the retained-tail bound + // (`slice(-0)` keeps everything), `maxMessageBytes: 0` makes every response fatal, and a bad + // document cap fails later in the read path instead of at load. + assertPositiveInteger(providerId, 'maxStderrBytes', resolved.maxStderrBytes) + assertPositiveInteger(providerId, 'maxMessageBytes', resolved.maxMessageBytes) + assertPositiveInteger(providerId, 'maxDocumentBytes', resolved.maxDocumentBytes) +} + +/** Reject a timer value Node would clamp instead of scheduling as configured. */ +function assertTimer(providerId: string, name: string, value: number): void { + if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) { + throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) + } +} + +/** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */ +function assertPositiveInteger(providerId: string, name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer`) + } +} + +/** A pooled generic provider: one server process per canonical workspace, created on demand. */ +class LocalLspProvider implements LspProvider { + readonly id: LspProviderId + readonly extensionToLanguage: Readonly> + /** One live instance per canonical workspace realpath. */ + private readonly instances = new Map() + /** One complete source-read→open→query→close serialization tail per canonical workspace. */ + private readonly queues = new Map>() + private disposed = false + + constructor( + providerId: string, + private readonly config: ResolvedServerConfig, + private readonly childEnv: Record, + private readonly executable: string, + ) { + this.id = LspProviderId(providerId) + this.extensionToLanguage = config.extensionToLanguage + } + + /** Read the disposed flag through a method so a `query()` await cannot narrow it to a literal. */ + private isDisposed(): boolean { + return this.disposed + } + + /** Reject work that cannot publish or use a provider-owned instance. */ + private assertActive(signal?: AbortSignal): void { + /* v8 ignore next -- the seam unregisters this provider before disposal; direct in-flight calls + exercise the post-await check instead. */ + if (this.isDisposed()) throw new LspError('lsp-local provider is disposed', 'LSP_DISPOSED') + if (signal?.aborted) throw abortError(signal) + } + + async query(request: LspProviderQuery, signal?: AbortSignal): Promise { + // Honor an already-aborted signal before host I/O so a canceled request never starts a server. + this.assertActive(signal) + const workspace = await canonicalizeWorkspace(request.workspaceRoot, signal) + this.assertActive(signal) + return this.enqueue(workspace, signal, async () => { + this.assertActive(signal) + // Read inside the workspace queue but before spawning: a queued query sees current bytes when + // its turn starts, while an invalid source still cannot leave an idle process pooled. + const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes, signal) + // Disposal may have snapshotted the instance map while host I/O was pending. Re-check before a + // synchronous get-or-create so every spawned process remains owned by teardown. + this.assertActive(signal) + let instance = this.instanceFor(workspace) + if (instance.dead) { + this.evictIfCurrent(workspace, instance) + instance = this.instanceFor(workspace) + } + try { + return await instance.query(request, source, signal) + } finally { + // Drop a crashed slot only when it still owns this instance; a replacement must survive. + if (instance.dead) this.evictIfCurrent(workspace, instance) + } + }) + } + + /** Serialize one complete query lifecycle for a canonical workspace. */ + private enqueue(workspace: string, signal: AbortSignal | undefined, run: () => Promise): Promise { + const previous = this.queues.get(workspace) ?? Promise.resolve() + const result = abortable(previous, signal).then(run) + // The tail follows the actual prior work even when this caller aborts its wait. It never rejects, + // so later callers serialize without inheriting an earlier query's outcome. + const tail = previous.then(() => result).then(() => undefined, () => undefined) + this.queues.set(workspace, tail) + void tail.then(() => { + if (this.queues.get(workspace) === tail) this.queues.delete(workspace) + }) + return result + } + + /** Return or synchronously publish the one instance for a canonical workspace. */ + private instanceFor(workspace: string): LspInstance { + this.assertActive() + const existing = this.instances.get(workspace) + if (existing !== undefined) return existing + const created = this.createInstance(workspace) + this.instances.set(workspace, created) + return created + } + + /** Drop the slot iff it still contains this instance. */ + private evictIfCurrent(workspace: string, instance: LspInstance): void { + /* v8 ignore next -- mismatch requires another query to replace the slot before this finally runs. */ + if (this.instances.get(workspace) === instance) this.instances.delete(workspace) + } + + private createInstance(workspace: string): LspInstance { + const spec: InstanceSpec = { + command: this.executable, + args: this.config.args, + cwd: workspace, + env: this.childEnv, + configuration: this.config.configuration, + initializationOptions: this.config.initializationOptions, + maxMessageBytes: this.config.maxMessageBytes, + maxStderrBytes: this.config.maxStderrBytes, + shutdownTimeoutMs: this.config.shutdownTimeoutMs, + killGraceMs: this.config.killGraceMs, + } + return new LspInstance(spec) + } + + /** Dispose every live instance and block further queries. */ + async disposeAll(): Promise { + this.disposed = true + const live = [...this.instances.values()] + const draining = [...this.queues.values()] + this.instances.clear() + await Promise.all([ + ...live.map(instance => instance.dispose()), + ...draining, + ]) + this.queues.clear() + } +} + +/** The ambient env minus credential-shaped vars, plus the config's explicit env. */ +function buildChildEnv(extra: Record): Record { + const scrubbed = Object.entries(process.env).filter( + ([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key), + ) as [string, string][] + return { ...Object.fromEntries(scrubbed), ...extra } +} + +/** + * Resolve the server executable to an absolute path: an absolute command is verified directly; a + * bare command is looked up on the child's PATH. Fails loudly when nothing is executable. + */ +function resolveExecutable(command: string, childEnv: Record): string { + if (isAbsolute(command)) { + // Verify an absolute command too, so an unavailable one fails at load, not on the first query. + if (!isExecutableFileSync(command)) { + throw new Error(`lsp-local: command "${command}" is not an executable file`) + } + return command + } + /* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */ + const pathValue = childEnv.PATH ?? process.env.PATH ?? '' + for (const dir of pathValue.split(delimiter)) { + if (dir === '') continue + const candidate = join(dir, command) + if (isExecutableFileSync(candidate)) return candidate + } + throw new Error(`lsp-local: command "${command}" was not found on PATH`) +} + +/** Synchronous regular-file and executable check used only at load-time resolution. */ +function isExecutableFileSync(path: string): boolean { + try { + if (!statSync(path).isFile()) return false + accessSync(path, constants.X_OK) + return true + } catch { + return false + } +} diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts new file mode 100644 index 0000000000..74381c1483 --- /dev/null +++ b/packages/lsp/lsp-local/src/instance.ts @@ -0,0 +1,334 @@ +/** + * One language-server instance: a connection plus the initialize handshake, the serialized abortable + * query queue, the transient `didOpen`→request→`didClose` lifecycle, and bounded teardown. One + * instance owns one `(provider id, canonical workspace)` process. Queries serialize through a single + * queue so a cancellation that fails to stop the server can terminate it without killing unrelated + * work; distinct instances run in parallel. + * @module @deepseek-ai/dsh-lsp-local/instance + */ + +import { pathToFileURL } from 'node:url' +import { LspError } from '@deepseek-ai/dsh-lsp' +import type { + LspOperation, + LspProviderQuery, + LspQueryResult, +} from '@deepseek-ai/dsh-lsp' +import { deadline } from '@deepseek-ai/dsh-timeout' +import { abortable, abortError } from './abort.ts' +import { LspConnection } from './connection.ts' +import type { ConnectionSpec } from './connection.ts' +import type { HostSource } from './host.ts' +import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' +import { + negotiatePositionEncoding, + normalizeHover, + normalizeLocations, + requestMethod, + supportsOperation, + supportsTransientOpen, +} from './translate.ts' + +/** Everything an instance needs beyond the connection spec. */ +export interface InstanceSpec extends ConnectionSpec { + /** Static `initialize` options forwarded to the server. */ + readonly initializationOptions: unknown + /** Graceful `shutdown`/`exit` budget before escalation (ms). */ + readonly shutdownTimeoutMs: number + /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ + readonly killGraceMs: number +} + +/** + * A single initialized server process. Not exported as a provider — the provider single-flights and + * pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down. + */ +export class LspInstance { + private readonly connection: LspConnection + private capabilities: WireServerCapabilities | undefined + /** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */ + private queue: Promise = Promise.resolve() + private disposed = false + /** The one teardown transaction shared by abort, failure, and explicit disposal. */ + private teardownPromise: Promise | undefined + /** Set once the process closes, so the pool can synchronously skip a dead instance. */ + private processClosed = false + /** Populated once `initialize` succeeds; a failed handshake rejects every query. */ + private readonly ready: Promise + + /** + * @param spec - the launch, initialize, and teardown parameters. + */ + constructor(private readonly spec: InstanceSpec) { + this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params)) + this.ready = this.initialize() + // A handshake rejection must not surface as an unhandled rejection before the first query awaits + // it; queries attach the real handler. + this.ready.catch(() => {}) + void this.connection.closed.then(() => { this.processClosed = true }) + } + + /** Synchronous liveness check: true once the process has closed or the instance was disposed. */ + get dead(): boolean { + return this.processClosed || this.disposed + } + + /** + * Run one query through the serialized queue. + * @param request - the resolved provider query. + * @param source - the pre-validated, already-read host source (the provider reads before spawning). + * @param signal - optional cancellation for this query's full lifecycle. + * @returns the normalized result. + */ + query(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { + // Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query + // hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up + // rather than block on the shared tail forever. + const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal)) + // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The + // tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up + // on the wait does not deserialize the queue. + this.queue = this.queue.then(() => run).then(() => undefined, () => undefined) + return run + } + + private async initialize(): Promise { + const initializeResult = await this.connection.request('initialize', { + processId: process.pid, + rootUri: pathToFileURL(this.spec.cwd).href, + workspaceFolders: [{ uri: pathToFileURL(this.spec.cwd).href, name: 'workspace' }], + capabilities: CLIENT_CAPABILITIES, + initializationOptions: this.spec.initializationOptions, + }) as WireInitializeResult + const capabilities = initializeResult.capabilities + // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here. + negotiatePositionEncoding(capabilities.positionEncoding) + this.capabilities = capabilities + await this.connection.notify('initialized', {}) + } + + private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { + if (this.disposed) throw new LspError('LSP instance was disposed', 'LSP_DISPOSED') + /* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */ + if (signal?.aborted) throw abortError(signal) + // Observe abort during the handshake wait, and never pool a poisoned instance: if the wait ends + // in failure — an abort on a still-pending handshake, OR `initialize` rejecting (utf-8 + // negotiation, malformed result) without the process exiting — tear the instance down so a + // permanently-rejecting/pending `ready` can't make every later query for this workspace fail. + try { + await abortable(this.ready, signal) + } catch (error) { + if (!this.dead) { + await this.startTeardown() + } + throw error + } + const capabilities = this.capabilities + /* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */ + if (capabilities === undefined) throw new Error('LSP instance is not initialized') + if (!supportsOperation(capabilities, request.operation)) { + throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION') + } + if (!supportsTransientOpen(capabilities.textDocumentSync)) { + throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION') + } + + const uri = pathToFileURL(source.canonicalPath).href + let opened = false + try { + /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */ + if (signal?.aborted) throw abortError(signal) + try { + await abortable(this.connection.notify('textDocument/didOpen', { + textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, + }), signal) + } catch (error) { + // A canceled backpressured write or failed stdin leaves the protocol stream unusable before + // `opened` can arm the didClose cleanup. Teardown here makes the pool evict the instance. + await this.startTeardown() + throw error + } + opened = true + const payload = await this.sendRequest(request.operation, uri, request.position, signal) + return this.normalize(request.operation, payload) + } finally { + // A disposed or closed instance (e.g. an aborted request whose server ignored + // `$/cancelRequest`) is already tearing down; sending didClose would race that teardown and let + // the next queued query's document lifecycle overlap the still-active request. + if (opened && !this.dead) { + try { + await this.connection.notify('textDocument/didClose', { textDocument: { uri } }) + } catch { + // A close-write failure does not replace the settled result/error, but the instance can no + // longer be trusted: invalidate it and await bounded process termination. + try { + await this.startTeardown() + } catch { + /* v8 ignore next -- teardown owns all expected process races; this only preserves the + already-settled query outcome if an unexpected cleanup primitive itself rejects. */ + } + } + } + } + } + + private async sendRequest( + operation: LspOperation, + uri: string, + position: LspProviderQuery['position'], + signal?: AbortSignal, + ): Promise { + const params = { + textDocument: { uri }, + position: { line: position.line, character: position.character }, + // findReferences always includes declarations: the caller gets no flag and impact analysis + // never omits the defining site. + ...(operation === 'findReferences' ? { context: { includeDeclaration: true } } : {}), + } + const requestId = this.connection.peekNextId() + const send = this.connection.request(requestMethod(operation), params) + if (signal === undefined) return send + return this.raceAbort(send, requestId, signal) + } + + /** + * Race a pending request against abort. On abort, send `$/cancelRequest` and give the server a + * bounded grace to acknowledge; if it does not settle in time, invalidate and tear down the + * instance so the still-active request cannot overlap the next queued query's document lifecycle. + */ + private async raceAbort(send: Promise, requestId: number, signal: AbortSignal): Promise { + try { + return await abortable(send, signal) + } catch (error) { + if (!signal.aborted) throw error + this.connection.cancel(requestId) + // Wait, bounded, for the server to honor the cancellation. If it does not, the request is still + // running: terminate the instance (disposal awaits process close) so nothing outlives the query. + const grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE') + try { + // `settled` is true if the request finished (either outcome) before the grace elapsed. + const settled = await Promise.race([ + send.then(markSettled, markSettled), + new Promise((resolve) => { + /* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */ + if (grace.signal.aborted) { resolve(false); return } + grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true }) + }), + ]) + if (!settled) await this.startTeardown() + } finally { + grace[Symbol.dispose]() + } + throw error + } + } + + private normalize(operation: LspOperation, payload: unknown): LspQueryResult { + if (operation === 'hover') { + return { kind: 'hover', hover: normalizeHover(payload) } + } + // `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning), + // and every `file:` location URI is relative to it — so it is the root a caller must relativize + // display paths against, not the request's possibly-symlinked workspaceRoot. + return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd } + } + + private answerServerRequest(method: string, params: unknown): Promise { + if (method === 'workspace/configuration') { + // Answer every requested item with the one static configuration value. + const record = params as { items?: unknown[] } | null + /* v8 ignore next -- a configuration request always carries an items array; the empty fallback is defensive. */ + const items = Array.isArray(record?.items) ? record.items : [] + return Promise.resolve(items.map(() => this.spec.configuration)) + } + if (LIFECYCLE_NOOP_METHODS.has(method)) { + // Accept lifecycle bookkeeping requests with an empty result; we register nothing dynamic. + return Promise.resolve(null) + } + if (method === 'workspace/applyEdit') { + // This host never applies edits or runs commands. + return Promise.reject(new Error('workspace/applyEdit is not permitted by this host')) + } + return Promise.reject(new Error(`unsupported server request: ${method}`)) + } + + /** + * Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting + * process close so nothing outlives disposal. + */ + async dispose(): Promise { + await this.startTeardown() + } + + /** Publish disposal once and make every caller await the same quiescence boundary. */ + private startTeardown(): Promise { + this.disposed = true + this.teardownPromise ??= this.tearDown() + return this.teardownPromise + } + + private async tearDown(): Promise { + const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') + try { + await this.gracefulShutdown(shutdownDeadline.signal) + } catch { + // Graceful shutdown failed or timed out; process-group cleanup below remains authoritative. + } finally { + shutdownDeadline[Symbol.dispose]() + } + await this.forceTerminate() + } + + /** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */ + private async gracefulShutdown(signal: AbortSignal): Promise { + await abortable(this.connection.request('shutdown', null), signal) + await this.connection.notify('exit', null) + await abortable(this.connection.closed, signal) + } + + /** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */ + private async forceTerminate(): Promise { + this.connection.terminate() + const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') + let groupExited: boolean + try { + groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal) + } finally { + graceDeadline[Symbol.dispose]() + } + if (!groupExited) this.connection.kill() + await Promise.all([ + this.connection.closed, + this.connection.waitForProcessGroupExit(), + ]) + } +} + +/** Server→client request methods this host acknowledges with an empty result (no dynamic registration). */ +const LIFECYCLE_NOOP_METHODS = new Set([ + 'window/workDoneProgress/create', + 'client/registerCapability', + 'client/unregisterCapability', +]) + +/** Mark a settled request in the cancel-grace race (either outcome means the request finished). */ +function markSettled(): boolean { + return true +} + +/** + * The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and + * configuration, markdown/plaintext hover, and link support for definition/implementation. No + * dynamic registration; the server's returned capabilities are authoritative. + */ +const CLIENT_CAPABILITIES = { + general: { positionEncodings: ['utf-16'] }, + workspace: { workspaceFolders: true, configuration: true }, + textDocument: { + synchronization: { dynamicRegistration: false }, + hover: { contentFormat: ['markdown', 'plaintext'] }, + definition: { linkSupport: true }, + implementation: { linkSupport: true }, + references: {}, + }, +} as const diff --git a/packages/lsp/lsp-local/src/invariant.ts b/packages/lsp/lsp-local/src/invariant.ts new file mode 100644 index 0000000000..52ebd16dc0 --- /dev/null +++ b/packages/lsp/lsp-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-lsp-local`. + * @module @deepseek-ai/dsh-lsp-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-lsp-local' + +/** Cordis companion plugin name. */ +export const name = 'lsp-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: process pools and per-workspace queues are private implementation state, + * and this provider publishes no independent lifecycle event stream or enumerable snapshot. + */ +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/lsp/lsp-local/src/protocol.ts b/packages/lsp/lsp-local/src/protocol.ts new file mode 100644 index 0000000000..abceb04d71 --- /dev/null +++ b/packages/lsp/lsp-local/src/protocol.ts @@ -0,0 +1,80 @@ +/** + * The subset of LSP wire types this generic host reads and writes: initialize capabilities, the four + * request results (`Location`, `LocationLink`, `Hover`), and the `textDocumentSync` shapes used to + * decide transient-open support. Types only. Fields absent from a real server payload stay optional; + * the translation layer normalizes them into the seam's closed unions. + * @module @deepseek-ai/dsh-lsp-local/protocol + */ + +/** A zero-based UTF-16 position on the wire (the protocol's `Position`). */ +export interface WirePosition { + readonly line: number + readonly character: number +} + +/** A wire range (`Range`). */ +export interface WireRange { + readonly start: WirePosition + readonly end: WirePosition +} + +/** A `Location`: a document URI plus a range. */ +export interface WireLocation { + readonly uri: string + readonly range: WireRange +} + +/** A `LocationLink`: the target uri plus the selection range to focus. */ +export interface WireLocationLink { + readonly targetUri: string + readonly targetSelectionRange: WireRange + readonly targetRange?: WireRange +} + +/** A `MarkupContent` hover body (`markdown` or `plaintext`). */ +export interface WireMarkupContent { + readonly kind: 'markdown' | 'plaintext' + readonly value: string +} + +/** A `MarkedString` object form (`{ language, value }`); the string form is a bare `string`. */ +export interface WireMarkedStringObject { + readonly language: string + readonly value: string +} + +/** One `MarkedString`: a raw string or a language-tagged code block. */ +export type WireMarkedString = string | WireMarkedStringObject + +/** A `Hover`: contents in any of the protocol's three encodings, plus an optional range. */ +export interface WireHover { + readonly contents: WireMarkupContent | WireMarkedString | readonly WireMarkedString[] + readonly range?: WireRange +} + +/** The legacy enum form of `textDocumentSync` (`0` None, `1` Full, `2` Incremental). */ +export type WireTextDocumentSyncKind = 0 | 1 | 2 + +/** The options form of `textDocumentSync` (`{ openClose, change }`). */ +export interface WireTextDocumentSyncOptions { + readonly openClose?: boolean + readonly change?: WireTextDocumentSyncKind +} + +/** A `ServerCapabilities.provider` slot: a boolean or an options object (both mean "supported"). */ +export type WireProviderCapability = boolean | Record | undefined + +/** The `ServerCapabilities` fields this host inspects. */ +export interface WireServerCapabilities { + readonly positionEncoding?: string + readonly textDocumentSync?: WireTextDocumentSyncKind | WireTextDocumentSyncOptions + readonly definitionProvider?: WireProviderCapability + readonly referencesProvider?: WireProviderCapability + readonly implementationProvider?: WireProviderCapability + readonly hoverProvider?: WireProviderCapability +} + +/** The `initialize` result envelope. */ +export interface WireInitializeResult { + readonly capabilities: WireServerCapabilities +} diff --git a/packages/lsp/lsp-local/src/translate.ts b/packages/lsp/lsp-local/src/translate.ts new file mode 100644 index 0000000000..a49246c8bb --- /dev/null +++ b/packages/lsp/lsp-local/src/translate.ts @@ -0,0 +1,235 @@ +/** + * Pure protocol translation for the local host: what the server's capabilities allow, and how its + * `Location`/`LocationLink`/`Hover` payloads normalize into the seam's closed result unions. No I/O + * or process state — every function here is a pure transform, which the fake-stdio tests pin exactly. + * @module @deepseek-ai/dsh-lsp-local/translate + */ + +import type { + LspHover, + LspLocation, + LspOperation, + LspRange, +} from '@deepseek-ai/dsh-lsp' +import { LspError } from '@deepseek-ai/dsh-lsp' +import { assertNever } from '@deepseek-ai/dsh-llm' +import type { + WireHover, + WireLocation, + WireLocationLink, + WireMarkedString, + WireProviderCapability, + WireRange, + WireServerCapabilities, + WireTextDocumentSyncKind, +} from './protocol.ts' + +/** + * The `textDocument/*` request method for each seam operation. + * @param operation - the seam operation to map. + * @returns the LSP request method name. + */ +export function requestMethod(operation: LspOperation): string { + switch (operation) { + case 'goToDefinition': return 'textDocument/definition' + case 'findReferences': return 'textDocument/references' + case 'goToImplementation': return 'textDocument/implementation' + case 'hover': return 'textDocument/hover' + /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */ + default: return assertNever(operation, 'requestMethod') + } +} + +/** The `ServerCapabilities` provider field backing each operation. */ +function capabilityValue(capabilities: WireServerCapabilities, operation: LspOperation): WireProviderCapability { + switch (operation) { + case 'goToDefinition': return capabilities.definitionProvider + case 'findReferences': return capabilities.referencesProvider + case 'goToImplementation': return capabilities.implementationProvider + case 'hover': return capabilities.hoverProvider + /* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */ + default: return assertNever(operation, 'capabilityValue') + } +} + +/** A provider capability is present when the server sent `true` or an options object (not `false`/absent). */ +function supportsCapability(value: WireProviderCapability): boolean { + if (value === undefined) return false + if (typeof value === 'boolean') return value + return true +} + +/** + * Whether the server advertises the requested operation. + * @param capabilities - the server's `initialize` capabilities. + * @param operation - the seam operation to check. + * @returns true when the corresponding provider capability is present. + */ +export function supportsOperation(capabilities: WireServerCapabilities, operation: LspOperation): boolean { + return supportsCapability(capabilityValue(capabilities, operation)) +} + +/** + * Whether a `textDocumentSync` value permits the transient `didOpen`/`didClose` this host relies on. + * The legacy enum form implies open/close for `Full`/`Incremental`; the options form requires an + * explicit `openClose: true`, because the protocol defaults an omitted `openClose` to false. + * @param sync - the server's advertised `textDocumentSync` capability. + * @returns true when transient open/close is supported. + */ +export function supportsTransientOpen(sync: WireServerCapabilities['textDocumentSync']): boolean { + if (sync === undefined) return false + if (typeof sync === 'number') return isOpenCloseKind(sync) + return sync.openClose === true +} + +/** Legacy enum: `Full` (1) or `Incremental` (2) imply open/close support; `None` (0) does not. */ +function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean { + return kind === 1 || kind === 2 +} + +/** + * Normalize the negotiated position encoding. An omitted encoding defaults to `utf-16`; any value + * other than `utf-16` is a protocol error this host does not support. + * @param encoding - the server's advertised `positionEncoding`, if any. + * @returns the string `'utf-16'`. + * @throws Error for any non-`utf-16` encoding. + */ +export function negotiatePositionEncoding(encoding: string | undefined): 'utf-16' { + if (encoding === undefined || encoding === 'utf-16') return 'utf-16' + throw new Error(`server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`) +} + +/** Convert a wire range to the seam's range (structurally identical, but re-shaped as `readonly`). */ +function toRange(range: WireRange): LspRange { + return { + start: { line: range.start.line, character: range.start.character }, + end: { line: range.end.line, character: range.end.character }, + } +} + +/** Whether a record is a `LocationLink` (has `targetUri` + `targetSelectionRange`). */ +function isLocationLink(value: Record): boolean { + return typeof value.targetUri === 'string' && isRange(value.targetSelectionRange) +} + +/** Whether a record is a `Location` (has string `uri` + a range). */ +function isLocation(value: Record): boolean { + return typeof value.uri === 'string' && isRange(value.range) +} + +/** Structural range guard used by both location shapes. */ +function isRange(value: unknown): value is WireRange { + if (value === null || typeof value !== 'object') return false + const range = value as Record + return isPosition(range.start) && isPosition(range.end) +} + +/** Structural position guard. */ +function isPosition(value: unknown): boolean { + if (value === null || typeof value !== 'object') return false + const position = value as Record + return isProtocolCoordinate(position.line) && isProtocolCoordinate(position.character) +} + +/** Whether a wire coordinate is a valid nonnegative integer. */ +function isProtocolCoordinate(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 +} + +/** + * Normalize a navigation result (`Location`, `Location[]`, `LocationLink[]`, or `null`) to the seam's + * locations. `Location` maps directly; `LocationLink` maps `targetUri` + `targetSelectionRange`. + * @param payload - the raw `textDocument/definition|references|implementation` result. + * @returns the normalized locations (empty for `null`/`[]`). + * @throws Error when an element is neither a `Location` nor a `LocationLink`. + */ +export function normalizeLocations(payload: unknown): LspLocation[] { + if (payload === null) return [] + if (payload === undefined) throw malformedResponse('LSP navigation result was missing') + const elements = Array.isArray(payload) ? payload : [payload] + const locations: LspLocation[] = [] + for (const element of elements) { + if (element === null || typeof element !== 'object') { + throw malformedResponse('LSP navigation result contained a non-object entry') + } + const record = element as Record + if (isLocationLink(record)) { + const link = record as unknown as WireLocationLink + locations.push({ uri: link.targetUri, range: toRange(link.targetSelectionRange) }) + } else if (isLocation(record)) { + const location = record as unknown as WireLocation + locations.push({ uri: location.uri, range: toRange(location.range) }) + } else { + throw malformedResponse('LSP navigation result contained neither a Location nor a LocationLink') + } + } + return locations +} + +/** Render one `MarkedString` (string form verbatim; object form as a language-tagged fenced block). */ +function renderMarkedString(value: WireMarkedString): string { + if (typeof value === 'string') return value + return `\`\`\`${value.language}\n${value.value}\n\`\`\`` +} + +/** + * Normalize a `Hover` (or `null`) to the seam's hover. `MarkupContent` uses its `value`; a string + * `MarkedString` is verbatim; a language-tagged `MarkedString` becomes a fenced code block; an array + * joins its rendered parts with one blank line. The model-facing tool owns the complete result cap. + * @param payload - the raw `textDocument/hover` result. + * @returns the normalized hover, or `null` when there is no content. + * @throws Error when the payload is a non-null, non-object, or structurally invalid hover. + */ +export function normalizeHover(payload: unknown): LspHover | null { + if (payload === null) return null + if (payload === undefined) throw malformedResponse('LSP hover result was missing') + if (typeof payload !== 'object') throw malformedResponse('LSP hover result was not an object') + const hover = payload as unknown as WireHover + const contents = renderHoverContents(hover.contents) + if (contents === '') return null + const range = hover.range + if (range === undefined) return { contents } + if (!isRange(range)) throw malformedResponse('LSP hover result contained a malformed range') + return { contents, range: toRange(range) } +} + +/** Render the three `Hover.contents` encodings into one string (input is untrusted wire data). */ +function renderHoverContents(contents: unknown): string { + if (contents === null || contents === undefined) { + throw malformedResponse('LSP hover result had no contents') + } + if (typeof contents === 'string') return contents + if (Array.isArray(contents)) { + return contents.map((value) => { + if (isMarkedString(value)) return renderMarkedString(value) + throw malformedResponse('LSP hover contents contained a malformed MarkedString') + }).join('\n\n') + } + if (typeof contents !== 'object') { + throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array') + } + const record = contents as Record + if (record.kind === 'markdown' || record.kind === 'plaintext') { + if (typeof record.value !== 'string') { + throw malformedResponse('LSP hover MarkupContent value was not a string') + } + return record.value + } + if (typeof record.language === 'string' && typeof record.value === 'string') { + return renderMarkedString({ language: record.language, value: record.value }) + } + throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array') +} + +/** Whether an untrusted value is either form of `MarkedString`. */ +function isMarkedString(value: unknown): value is WireMarkedString { + if (typeof value === 'string') return true + if (value === null || typeof value !== 'object') return false + const record = value as Record + return typeof record.language === 'string' && typeof record.value === 'string' +} + +/** Create the stable structured error used for malformed server result payloads. */ +function malformedResponse(message: string): LspError { + return new LspError(message, 'LSP_MALFORMED_RESPONSE') +} diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..a2da86d87c --- /dev/null +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -0,0 +1,73 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +/** + * Keyless built-artifact smoke: plain Node imports `@deepseek-ai/dsh-lsp` and + * `@deepseek-ai/dsh-lsp-local` by name through their exports maps, spawns the fixture server, runs + * one query (exercising real `Content-Length` framing over `lib/index.js`), and disposes (exercising + * subprocess cleanup). Unit tests use `src/`; this pins the downstream `lib/` path. Skips when `lib/` + * is absent; CI runs it after the build. + */ + +const pkgDir = fileURLToPath(new URL('..', import.meta.url)) +const seamLib = join(pkgDir, '../lsp/lib/index.js') +const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) + +const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) + +let root: string +let ws: string + +beforeAll(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-built-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') +}) + +afterAll(async () => { + if (root) await rm(root, { recursive: true, force: true }) +}) + +describe.skipIf(!built)('built lib real load path (plain node)', () => { + it('runs a query through lib/index.js and disposes cleanly, framing over the base protocol', async () => { + const location = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }) + const script = ` + const { Context } = await import('cordis') + const { default: Lsp } = await import('@deepseek-ai/dsh-lsp') + const LspLocal = await import('@deepseek-ai/dsh-lsp-local') + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + servers: { + fake: { + command: ${JSON.stringify(process.execPath)}, + args: [${JSON.stringify(fixtureServer)}], + env: { LSP_FAKE_DEF: ${JSON.stringify(location)} }, + extensionToLanguage: { '.ts': 'typescript' }, + }, + }, + }) + const result = await ctx.lsp.query({ operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) + console.log(JSON.stringify(result)) + await ctx.fiber.dispose() + ` + const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) + const exitCode = await new Promise(resolve => child.on('close', resolve)) + + expect(exitCode, `stderr:\n${stderr}`).toBe(0) + const lastLine = stdout.trim().split('\n').at(-1) ?? '' + const result = JSON.parse(lastLine) as { kind: string; locations: unknown[] } + expect(result.kind).toBe('locations') + expect(result.locations).toHaveLength(1) + }, 60_000) +}) diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts new file mode 100644 index 0000000000..6d9ca6d6a3 --- /dev/null +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -0,0 +1,240 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { fileURLToPath } from 'node:url' +import { LspConnection } from '@deepseek-ai/dsh-lsp-local' + +const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) + +/** A recorded server→client request the test's handler saw. */ +interface SeenRequest { method: string; params: unknown } + +let open: LspConnection[] = [] + +afterEach(async () => { + for (const conn of open) { + conn.kill() + await conn.closed + } + open = [] +}) + +/** Spawn the fixture as a raw connection, with a scripted server-request handler. */ +function connect( + env: Record, + onServerRequest: (method: string, params: unknown) => Promise = () => Promise.resolve(null), + seen?: SeenRequest[], +): LspConnection { + const conn = new LspConnection({ + command: process.execPath, + args: [fixtureServer], + cwd: process.cwd(), + env: { ...process.env as Record, ...env }, + maxMessageBytes: 16_000_000, + maxStderrBytes: 100_000, + configuration: { setting: 42 }, + }, (method, params) => { + seen?.push({ method, params }) + return onServerRequest(method, params) + }) + open.push(conn) + return conn +} + +describe('LspConnection', () => { + it('completes an initialize request/response round-trip and exposes a pid', async () => { + const conn = connect({}) + const result = await conn.request('initialize', { capabilities: {} }) + expect(result).toMatchObject({ capabilities: { hoverProvider: true } }) + expect(conn.pid).toBeGreaterThan(0) + }) + + it('rejects a request when the server replies with an error', async () => { + const conn = connect({ LSP_FAKE_ERROR: '1' }) + await conn.request('initialize', { capabilities: {} }) + await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) + }) + + it('answers a server workspace/configuration request from static config', async () => { + const seen: SeenRequest[] = [] + const conn = connect( + { LSP_FAKE_ON_OPEN: 'configuration' }, + (method, params) => { + if (method === 'workspace/configuration') { + const items = (params as { items: unknown[] }).items + return Promise.resolve(items.map(() => ({ setting: 42 }))) + } + return Promise.resolve(null) + }, + seen, + ) + await conn.request('initialize', { capabilities: {} }) + await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await waitFor(() => seen.some(s => s.method === 'workspace/configuration')) + expect(seen[0]?.method).toBe('workspace/configuration') + }) + + it('drops a server→client notification without replying', async () => { + const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' }) + await conn.request('initialize', { capabilities: {} }) + await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + // No throw and the connection stays usable. + await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined() + }) + + it('sends an error response when the server-request handler rejects', async () => { + const seen: SeenRequest[] = [] + const conn = connect( + { LSP_FAKE_ON_OPEN: 'applyEdit' }, + method => method === 'workspace/applyEdit' ? Promise.reject(new Error('not permitted')) : Promise.resolve(null), + seen, + ) + await conn.request('initialize', { capabilities: {} }) + await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } }) + await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit')) + // The connection remains healthy after emitting the error response. + await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined() + }) + + it('fails all pending requests and kills the process on a framing error', async () => { + const conn = connect({ LSP_FAKE_GARBAGE: '1' }) + // The garbage byte precedes a valid initialize reply; unframed bytes are tolerated until a + // Content-Length header, so initialize still resolves. This exercises the decoder's resilience. + await expect(conn.request('initialize', { capabilities: {} })).resolves.toBeDefined() + }) + + it('rejects a new request issued after the process closes', async () => { + const conn = connect({}) + await conn.request('initialize', { capabilities: {} }) + conn.terminate() + await conn.closed + await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/exited|closed/) + }) + + it('cancel is a no-op-safe write after close', async () => { + const conn = connect({}) + await conn.request('initialize', { capabilities: {} }) + conn.terminate() + await conn.closed + expect(() => { conn.cancel(1) }).not.toThrow() + }) + + it('caps the retained stderr tail', async () => { + const conn = connect({}) + await conn.request('initialize', { capabilities: {} }) + expect(conn.stderrTail.length).toBeLessThanOrEqual(100_000) + }) +}) + +/** Spawn a raw connection running an inline node script as the "server". */ +function connectScript(script: string, maxStderrBytes = 100_000): LspConnection { + const conn = new LspConnection({ + command: process.execPath, + args: ['-e', script], + cwd: process.cwd(), + env: { ...process.env as Record }, + maxMessageBytes: 16_000_000, + maxStderrBytes, + configuration: null, + }, () => Promise.resolve(null)) + open.push(conn) + return conn +} + +describe('LspConnection edge behavior', () => { + it('fails a request when the command cannot be spawned', async () => { + const conn = new LspConnection({ + command: '/definitely/not/a/real/binary/xyz', + args: [], + cwd: process.cwd(), + env: {}, + maxMessageBytes: 1000, + maxStderrBytes: 1000, + configuration: null, + }, () => Promise.resolve(null)) + open.push(conn) + await expect(conn.request('initialize', {})).rejects.toThrow() + }) + + it('kills the process and fails pending requests on a framing error', async () => { + // Emit an invalid Content-Length header, corrupting the stream irrecoverably. + const conn = connectScript('process.stdout.write("Content-Length: abc\\r\\n\\r\\n{}"); setInterval(()=>{}, 1000)') + await expect(conn.request('initialize', {})).rejects.toThrow() + }) + + it('ignores a framed non-object message', async () => { + // Send a framed JSON number and a framed null (both non-objects) then a proper response to id 1. + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdout.write(fr("42"));process.stdout.write(fr("null"));' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true }) + }) + + it('drops a response for an unknown id', async () => { + // Emit a response for id 999 (never sent), then answer our real request. + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:999,result:{stray:true}})));' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true }) + }) + + it('caps the retained stderr tail at maxStderrBytes across chunks', async () => { + // Write stderr repeatedly so a later chunk arrives after the cap is already reached. + const conn = connectScript('setInterval(()=>process.stderr.write("E".repeat(200)), 5); setInterval(()=>{}, 1000)', 100) + await waitFor(() => conn.stderrTail.length >= 100) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(conn.stderrTail.length).toBe(100) + }) + + it('caps the retained stderr tail by bytes for multibyte UTF-8', async () => { + const conn = connectScript('process.stderr.write("😀😀")', 4) + await conn.closed + expect(conn.stderrTail).toBe('😀') + expect(Buffer.byteLength(conn.stderrTail)).toBe(4) + }) + + it('rejects with a fallback message when the error response has no message string', async () => { + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:{code:-1}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).rejects.toThrow(/LSP error response/) + }) + + it('rejects a pending request when the process exits mid-flight', async () => { + // Never responds, then exits shortly: the pending request must reject on close. + const conn = connectScript('setTimeout(()=>process.exit(0), 100)') + await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/) + }) + + it('rejects a pending request when child stdin closes but the process stays alive', async () => { + const conn = connectScript('require("node:fs").closeSync(0); setInterval(()=>{}, 1000)') + await new Promise(resolve => setTimeout(resolve, 100)) + const timeout = new Promise((_resolve, reject) => { + setTimeout(() => { reject(new Error('request timed out')) }, 1000) + }) + await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/) + }) + + it('ignores a frame that is neither a valid request nor a numeric-id response', async () => { + // A frame with a string id and no method: not dispatchable; the client must ignore it and still + // answer our real request. + const script = 'let b=Buffer.alloc(0);' + + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:"str-id"})));' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});' + const conn = connectScript(script) + await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true }) + }) +}) + +/** Poll a predicate until it holds or a deadline elapses. */ +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out') + await new Promise(resolve => setTimeout(resolve, 10)) + } +} diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts new file mode 100644 index 0000000000..c418ada519 --- /dev/null +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -0,0 +1,207 @@ +/** + * A scriptable fake LSP server over stdio for lsp-local tests. It speaks the real + * `Content-Length`-framed base protocol so it exercises the client's framing, initialize handshake, + * transient open/close, request mapping, and teardown — without a real language server. + * + * Behavior is driven by env vars so one file backs many scenarios: + * - LSP_FAKE_ENCODING: advertised positionEncoding (default utf-16; "utf-8" forces a mismatch). + * - LSP_FAKE_SYNC: textDocumentSync value as JSON (default 1/Full). + * - LSP_FAKE_CAPS: JSON of extra capability flags merged into the defaults. + * - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request. + * - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests). + * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test). + * - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request, + * simulating a server that dies while idle so the pool holds a dead instance (eviction test). + * - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds. + * - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path. + * - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received. + * - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized. + * - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification. + * - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response. + * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination. + * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). + * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of + * "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr. + * - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response. + * - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply. + * + * Run: node fixture-server.ts (Node's erasable TypeScript syntax support). + */ + +import { appendFileSync, closeSync } from 'node:fs' + +const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16' +const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1 +const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {} +const hang = process.env.LSP_FAKE_HANG === '1' +const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1' +const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1' +const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0) +const openMarker = process.env.LSP_FAKE_OPEN_MARKER +const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER +const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1' +const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1' +const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1' +const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0) +const exitMarker = process.env.LSP_FAKE_EXIT_MARKER +const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1' +const onOpen = process.env.LSP_FAKE_ON_OPEN +const errorReply = process.env.LSP_FAKE_ERROR === '1' +const garbage = process.env.LSP_FAKE_GARBAGE === '1' + +let serverRequestId = 10_000 +const pendingServerRequests = new Map() + +process.on('SIGTERM', () => { + markExit('TERM') + process.exit(0) +}) + +function resultFor(method: string): unknown { + switch (method) { + case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null) + case 'textDocument/references': return envJson('LSP_FAKE_REFS', null) + case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null) + case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null) + default: return null + } +} + +function envJson(name: string, fallback: unknown): unknown { + const raw = process.env[name] + return raw === undefined ? fallback : JSON.parse(raw) +} + +let buffer = Buffer.alloc(0) +process.stdin.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]) + for (;;) { + const sep = buffer.indexOf('\r\n\r\n') + if (sep < 0) break + const header = buffer.toString('ascii', 0, sep) + const match = /content-length:\s*(\d+)/i.exec(header) + if (!match) { buffer = buffer.subarray(sep + 4); continue } + const length = Number(match[1]) + const start = sep + 4 + if (buffer.length < start + length) break + const body = buffer.toString('utf8', start, start + length) + buffer = buffer.subarray(start + length) + handle(JSON.parse(body) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }) + } +}) + +function handle(message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }): void { + const { id, method } = message + // A frame with an id but no method is the client's REPLY to a server→client request; log it. + if (method === undefined && id !== undefined && pendingServerRequests.has(id)) { + const kind = pendingServerRequests.get(id) + pendingServerRequests.delete(id) + process.stderr.write(`REPLY ${kind} ${JSON.stringify({ result: message.result, error: message.error })}\n`) + return + } + if (method === 'initialize') { + if (garbage) process.stdout.write('this is not a framed message\r\n') + send({ + id, + result: { + capabilities: { + positionEncoding: enc, + textDocumentSync: sync, + definitionProvider: true, + referencesProvider: true, + implementationProvider: true, + hoverProvider: true, + ...(extraCaps as Record), + }, + }, + }) + return + } + if (method === 'shutdown') { + if (noShutdown) return + send({ id, result: null }) + return + } + if (method === 'exit') { + markExit('EXIT') + if (exitDelayMs > 0) { + setTimeout(() => { + markExit('CLEAN') + process.exit(0) + }, exitDelayMs) + return + } + markExit('CLEAN') + process.exit(0) + } + if (method === 'textDocument/didOpen') { + if (crashOnOpen) process.exit(1) + if (openMarker !== undefined) { + const params = message.params as { textDocument?: { text?: unknown } } | undefined + appendFileSync(openMarker, `${JSON.stringify(params?.textDocument?.text)}\n`) + } + if (onOpen !== undefined) emitServerRequest(onOpen) + return + } + if (method === 'initialized') { + if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n') + if (pauseStdinAfterInitialized) process.stdin.pause() + if (closeStdinAfterInitialized) closeSync(0) + return + } + if (method === 'textDocument/didClose') return + if (method?.startsWith('textDocument/')) { + if (hang) return + const reply = (): void => { + if (closeStdinAfterReply) closeSync(0) + if (errorReply) { + send({ id, error: { code: -32000, message: 'server refused the request' } }) + } else { + send({ id, result: resultFor(method) }) + } + // Simulate an idle death: answer this request, then exit before the next one arrives so the + // pool is left holding a dead instance. + if (exitAfterReply) setTimeout(() => process.exit(0), 20) + } + if (replyDelayMs > 0) setTimeout(reply, replyDelayMs) + else reply() + return + } + // Unknown request with an id: answer null so the client never stalls. + if (id !== undefined) send({ id, result: null }) +} + +/** Append one teardown event when the fixture is configured to expose process ordering. */ +function markExit(event: string): void { + if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`) +} + +/** Emit a server→client request and log the client's reply to stderr for the test to assert. */ +function emitServerRequest(kind: string): void { + if (kind === 'notification') { + send({ method: 'window/logMessage', params: { type: 3, message: 'hello' } }) + return + } + const id = serverRequestId++ + const method = kind === 'configuration' + ? 'workspace/configuration' + : kind === 'applyEdit' + ? 'workspace/applyEdit' + : kind === 'lifecycle' + ? 'client/registerCapability' + : 'window/showMessageRequest' + const params = kind === 'configuration' ? { items: [{ section: 'a' }, { section: 'b' }] } : {} + pendingServerRequests.set(id, method) + send({ id, method, params }) +} + +function send(message: Record): void { + const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }), 'utf8') + process.stdout.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body])) +} + +// Keep the event loop alive. +process.stdin.resume() +if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) { + setInterval(() => {}, 1000) +} diff --git a/packages/lsp/lsp-local/tests/framing.spec.ts b/packages/lsp/lsp-local/tests/framing.spec.ts new file mode 100644 index 0000000000..a197b2c0ca --- /dev/null +++ b/packages/lsp/lsp-local/tests/framing.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { encodeMessage, MessageDecoder } from '@deepseek-ai/dsh-lsp-local' + +/** Frame a message the way a server would, for decoder round-trips. */ +function frame(body: string): Buffer { + return Buffer.concat([Buffer.from(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`, 'ascii'), Buffer.from(body, 'utf8')]) +} + +describe('encodeMessage', () => { + it('prefixes a Content-Length header with the utf-8 byte length', () => { + const buffer = encodeMessage({ jsonrpc: '2.0', method: 'x', params: { s: 'é' } }) + const text = buffer.toString('utf8') + const body = '{"jsonrpc":"2.0","method":"x","params":{"s":"é"}}' + expect(text).toBe(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`) + }) +}) + +describe('MessageDecoder', () => { + it('decodes a single framed message', () => { + const decoder = new MessageDecoder(1_000) + expect(decoder.push(frame('{"id":1,"result":42}'))).toEqual([{ id: 1, result: 42 }]) + }) + + it('decodes multiple messages arriving in one chunk', () => { + const decoder = new MessageDecoder(1_000) + const chunk = Buffer.concat([frame('{"a":1}'), frame('{"b":2}')]) + expect(decoder.push(chunk)).toEqual([{ a: 1 }, { b: 2 }]) + }) + + it('reassembles a message split across chunks', () => { + const decoder = new MessageDecoder(1_000) + const full = frame('{"hello":"world"}') + expect(decoder.push(full.subarray(0, 10))).toEqual([]) + expect(decoder.push(full.subarray(10))).toEqual([{ hello: 'world' }]) + }) + + it('handles a header split from its body', () => { + const decoder = new MessageDecoder(1_000) + const body = '{"x":1}' + expect(decoder.push(Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'))).toEqual([]) + expect(decoder.push(Buffer.from(body, 'utf8'))).toEqual([{ x: 1 }]) + }) + + it('reads a case-insensitive header and ignores other headers', () => { + const decoder = new MessageDecoder(1_000) + const body = '{"ok":true}' + const chunk = Buffer.from(`content-length: ${body.length}\r\nContent-Type: x\r\n\r\n${body}`, 'utf8') + expect(decoder.push(chunk)).toEqual([{ ok: true }]) + }) + + it('rejects a body over the size limit', () => { + const decoder = new MessageDecoder(4) + expect(() => decoder.push(frame('{"big":true}'))).toThrow(/exceeds the 4-byte limit/) + }) + + it('rejects a missing Content-Length header', () => { + const decoder = new MessageDecoder(1_000) + expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(/missing Content-Length/) + }) + + it('rejects a non-numeric Content-Length', () => { + const decoder = new MessageDecoder(1_000) + expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(/invalid Content-Length/) + }) + + it('rejects a header block that never terminates', () => { + const decoder = new MessageDecoder(1_000) + const huge = Buffer.alloc((1 << 16) + 1, 0x41) + expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/) + }) + + it('rejects an oversized header block that includes its terminator', () => { + const decoder = new MessageDecoder(1_000) + const huge = Buffer.from(`Content-Length: 2\r\nX-Fill: ${'a'.repeat(70_000)}\r\n\r\n{}`, 'ascii') + expect(() => decoder.push(huge)).toThrow(/header exceeded .* bytes/) + }) + + it('rejects a non-JSON body', () => { + const decoder = new MessageDecoder(1_000) + expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/) + }) +}) diff --git a/packages/lsp/lsp-local/tests/host.spec.ts b/packages/lsp/lsp-local/tests/host.spec.ts new file mode 100644 index 0000000000..8629502fd1 --- /dev/null +++ b/packages/lsp/lsp-local/tests/host.spec.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { realpath } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { deadline } from '@deepseek-ai/dsh-timeout' +import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local' + +const execFileAsync = promisify(execFile) + +let root: string +let ws: string + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-'))) + ws = join(root, 'ws') + await mkdir(ws) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +const BIG = 1_000_000 + +describe('canonicalizeWorkspace', () => { + it('returns the realpath of a directory', async () => { + expect(await canonicalizeWorkspace(ws)).toBe(ws) + }) + + it('resolves a symlinked workspace to its target so aliases share identity', async () => { + const link = join(root, 'ws-link') + await symlink(ws, link) + expect(await canonicalizeWorkspace(link)).toBe(ws) + }) + + it('rejects a missing workspace', async () => { + await expect(canonicalizeWorkspace(join(root, 'nope'))).rejects.toThrow(/cannot be resolved/) + }) + + it('rejects a non-directory workspace', async () => { + const file = join(root, 'file.txt') + await writeFile(file, 'x') + await expect(canonicalizeWorkspace(file)).rejects.toThrow(/not a directory/) + }) +}) + +describe('readHostSource', () => { + it('reads a relative path against the workspace', async () => { + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') + const source = await readHostSource('a.ts', ws, BIG) + expect(source.canonicalPath).toBe(join(ws, 'a.ts')) + expect(source.text).toBe('const x = 1\n') + }) + + it('reads an absolute path inside the workspace', async () => { + const abs = join(ws, 'b.ts') + await writeFile(abs, 'b') + const source = await readHostSource(abs, ws, BIG) + expect(source.canonicalPath).toBe(abs) + }) + + it('accepts a source reached through a symlink that stays inside the workspace', async () => { + await mkdir(join(ws, 'real')) + await writeFile(join(ws, 'real', 'c.ts'), 'c') + await symlink(join(ws, 'real'), join(ws, 'linked')) + const source = await readHostSource('linked/c.ts', ws, BIG) + expect(source.canonicalPath).toBe(join(ws, 'real', 'c.ts')) + }) + + it('rejects a source whose canonical path escapes the workspace via symlink', async () => { + const outside = join(root, 'outside.ts') + await writeFile(outside, 'secret') + await symlink(outside, join(ws, 'escape.ts')) + await expect(readHostSource('escape.ts', ws, BIG)).rejects.toThrow(/outside the workspace/) + }) + + it('rejects an absolute source outside the workspace', async () => { + const outside = join(root, 'out.ts') + await writeFile(outside, 'x') + await expect(readHostSource(outside, ws, BIG)).rejects.toThrow(/outside the workspace/) + }) + + it('rejects a missing source', async () => { + await expect(readHostSource('nope.ts', ws, BIG)).rejects.toThrow(/cannot be resolved/) + }) + + it('rejects a non-regular source (directory)', async () => { + await mkdir(join(ws, 'dir')) + await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/) + }) + + it('rejects a FIFO with no writer without blocking in open', async () => { + const fifo = join(ws, 'pipe.ts') + await execFileAsync('mkfifo', [fifo]) + using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT') + await expect(readHostSource('pipe.ts', ws, BIG, d.signal)).rejects.toThrow(/not a regular file/) + }) + + it('honors a pre-aborted source read before filesystem work', async () => { + const controller = new AbortController() + controller.abort(new Error('source read cancelled')) + await expect(readHostSource('missing.ts', ws, BIG, controller.signal)).rejects.toThrow(/source read cancelled/) + }) + + it('treats the workspace root itself as inside, then rejects it as non-regular', async () => { + // filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the + // directory then fails the regular-file check. + await expect(readHostSource('.', ws, BIG)).rejects.toThrow(/not a regular file/) + }) + + it('rejects an oversized source', async () => { + await writeFile(join(ws, 'big.ts'), 'x'.repeat(100)) + await expect(readHostSource('big.ts', ws, 10)).rejects.toThrow(/over the 10-byte limit/) + }) + + it('rejects a non-UTF-8 source', async () => { + await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00])) + await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/) + }) + + it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => { + // The literal replacement char is valid UTF-8; a fatal decoder must accept it (only malformed + // byte sequences are rejected). + await writeFile(join(ws, 'repl.ts'), 'const s = "�"\n') + const source = await readHostSource('repl.ts', ws, BIG) + expect(source.text).toBe('const s = "�"\n') + }) +}) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts new file mode 100644 index 0000000000..328c8b7313 --- /dev/null +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' +import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' +import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' +import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' + +const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) + +let root: string +let ws: string +let live: LspInstance[] = [] + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-inst-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') +}) + +afterEach(async () => { + for (const instance of live) await instance.dispose() + live = [] + await rm(root, { recursive: true, force: true }) +}) + +function makeInstance(env: Record = {}, overrides: Partial = {}): LspInstance { + const instance = new LspInstance({ + command: process.execPath, + args: [fixtureServer], + cwd: ws, + env: { ...process.env as Record, ...env }, + configuration: { setting: 42 }, + initializationOptions: { init: true }, + maxMessageBytes: 16_000_000, + maxStderrBytes: 100_000, + shutdownTimeoutMs: 200, + killGraceMs: 200, + ...overrides, + }) + live.push(instance) + return instance +} + +function query(operation: LspProviderQuery['operation'] = 'goToDefinition'): LspProviderQuery { + return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' } +} + +/** Run a query against an instance, reading the source first the way the provider does. */ +async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'goToDefinition', signal?: AbortSignal): Promise { + const source = await readHostSource('a.ts', ws, 4_000_000) + return instance.query(query(operation), source, signal) +} + +/** Build an instance whose "server" is an inline node script (for teardown-escalation control). */ +function scriptInstance(script: string, overrides: Partial = {}): LspInstance { + const instance = new LspInstance({ + command: process.execPath, + args: ['-e', script], + cwd: ws, + env: { ...process.env as Record }, + configuration: null, + initializationOptions: null, + maxMessageBytes: 16_000_000, + maxStderrBytes: 100_000, + shutdownTimeoutMs: 150, + killGraceMs: 150, + ...overrides, + }) + live.push(instance) + return instance +} + +/** An inline server that answers initialize + definition and echoes a location. */ +const RESPONDING_SERVER = + 'let b=Buffer.alloc(0);' + + 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }) + +describe('LspInstance server-request handling', () => { + it('answers workspace/configuration with the static config per item', async () => { + const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() }) + // The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer + // keeps the query working. + await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' }) + }) + + it('accepts a lifecycle client/registerCapability request', async () => { + const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + }) + + it('rejects a workspace/applyEdit request but keeps serving', async () => { + const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + }) + + it('rejects an unknown server request but keeps serving', async () => { + const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + }) +}) + +describe('LspInstance query and abort', () => { + it('sends includeDeclaration for references', async () => { + const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) }) + await expect(run(instance, 'findReferences')).resolves.toMatchObject({ kind: 'locations' }) + }) + + it('rejects a query aborted before it starts', async () => { + const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) + const controller = new AbortController() + controller.abort(new Error('pre-abort')) + await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/pre-abort/) + }) + + it('cancels an in-flight request on abort and rejects', async () => { + const instance = makeInstance({ LSP_FAKE_HANG: '1' }) + const controller = new AbortController() + // Warm the instance first so the abort lands during the hanging request, not during startup. + const pending = run(instance, 'goToDefinition', controller.signal) + await new Promise(resolve => setTimeout(resolve, 300)) + controller.abort(new Error('mid-flight')) + await expect(pending).rejects.toThrow(/mid-flight/) + }) + + it('terminates the instance when the server ignores $/cancelRequest past the grace', async () => { + // The hang server never honors cancellation, so after the bounded grace the instance must be torn + // down (its process closed) rather than left with an active request. + const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 }) + const controller = new AbortController() + const pending = run(instance, 'goToDefinition', controller.signal) + await new Promise(resolve => setTimeout(resolve, 300)) + controller.abort(new Error('mid-flight')) + await expect(pending).rejects.toThrow(/mid-flight/) + expect(instance.dead).toBe(true) + }) + + it('resolves the cancel grace when the server honors $/cancelRequest', async () => { + // A server that answers $/cancelRequest by settling the pending request lets the grace race + // resolve via the request rather than the timeout, so the instance is NOT force-terminated. + const script = 'let b=Buffer.alloc(0),reqId=null;' + + 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length(resolve => setTimeout(resolve, 300)) + controller.abort(new Error('mid-flight')) + await expect(pending).rejects.toThrow(/mid-flight/) + // The server acknowledged cancellation within grace, so the instance was not force-killed. + expect(instance.dead).toBe(false) + await instance.dispose() + }) + + it('observes abort while awaiting a slow initialize handshake', async () => { + // A server that answers nothing (not even initialize) leaves `ready` pending; an abort must be + // observed during that wait instead of hanging the tool-timeout signal. + const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 }) + const controller = new AbortController() + const pending = run(instance, 'goToDefinition', controller.signal) + await new Promise(resolve => setTimeout(resolve, 150)) + controller.abort(new Error('handshake-abort')) + await expect(pending).rejects.toThrow(/handshake-abort/) + await instance.dispose() + }) + + it('terminates when abort interrupts a backpressured didOpen write', async () => { + // The fixture consumes initialized, then stops reading. A document larger than the stdio pipe + // keeps didOpen's write callback pending until cancellation forces bounded process teardown. + await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000)) + const marker = join(root, 'initialized.log') + const instance = makeInstance({ + LSP_FAKE_INITIALIZED_MARKER: marker, + LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: '1', + }, { + shutdownTimeoutMs: 100, + killGraceMs: 100, + }) + const controller = new AbortController() + const pending = run(instance, 'goToDefinition', controller.signal) + await waitForFile(marker) + // Let the client enter the large didOpen write after the fixture has paused stdin. + await new Promise(resolve => setTimeout(resolve, 100)) + controller.abort(new Error('didOpen-abort')) + await expect(pending).rejects.toThrow(/didOpen-abort/) + expect(instance.dead).toBe(true) + }) + + it('terminates when stdin fails during the didOpen write', async () => { + // Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose; + // the instance must still become dead so its provider can replace it. + await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000)) + const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, { + shutdownTimeoutMs: 100, + killGraceMs: 100, + }) + await expect(run(instance, 'goToDefinition')).rejects.toThrow() + expect(instance.dead).toBe(true) + }) + + it('rejects when the server lacks the operation capability', async () => { + const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) + await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) + }) + + it('propagates a server error response even when a signal is supplied (not an abort)', async () => { + // A live signal is passed, but the request fails for a server reason; the catch must rethrow + // without treating it as an abort. + const instance = makeInstance({ LSP_FAKE_ERROR: '1' }) + const controller = new AbortController() + await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/) + }) + + it('keeps a settled result but awaits teardown when didClose cannot be written', async () => { + const instance = makeInstance({ + LSP_FAKE_DEF: 'null', + LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1', + }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + await expect(run(instance, 'goToDefinition')).resolves.toEqual({ + kind: 'locations', + locations: [], + resolvedWorkspaceRoot: ws, + }) + expect(instance.dead).toBe(true) + }) +}) + +describe('LspInstance disposal', () => { + it('lets a server finish protocol exit before signal escalation', async () => { + const marker = join(root, 'graceful-exit.log') + const instance = makeInstance({ + LSP_FAKE_DEF: 'null', + LSP_FAKE_EXIT_DELAY_MS: '75', + LSP_FAKE_EXIT_MARKER: marker, + }, { shutdownTimeoutMs: 500 }) + await run(instance, 'goToDefinition') + await instance.dispose() + expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n') + }) + + it('is idempotent — a second dispose awaits close without error', async () => { + const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) + await run(instance, 'goToDefinition') + await instance.dispose() + await expect(instance.dispose()).resolves.toBeUndefined() + }) + + it('rejects a query after disposal', async () => { + const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) + await run(instance, 'goToDefinition') + await instance.dispose() + await expect(run(instance, 'goToDefinition')).rejects.toThrow(expect.objectContaining({ code: 'LSP_DISPOSED' })) + }) + + it('reports dead after the process closes', async () => { + const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) + await run(instance, 'goToDefinition') + await instance.dispose() + expect(instance.dead).toBe(true) + }) + + it('escalates to SIGKILL when the server ignores shutdown and SIGTERM', async () => { + // Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it. + const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});' + const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + await run(instance, 'goToDefinition') + await expect(instance.dispose()).resolves.toBeUndefined() + }) + + it('awaits a surviving process-group helper on every concurrent dispose', async () => { + const marker = join(root, 'helper.pid') + const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);' + const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");' + + `const helper=spawn(process.execPath,["-e",${JSON.stringify(helper)}],{stdio:"ignore"});` + + `writeFileSync(${JSON.stringify(marker)},String(helper.pid));` + + RESPONDING_SERVER + const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + await run(instance, 'goToDefinition') + const helperPid = Number(await readFile(marker, 'utf8')) + try { + const first = instance.dispose() + await instance.dispose() + expect(processAlive(helperPid)).toBe(false) + await first + } finally { + if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL') + } + }) + + it('carries a non-Error abort reason as a generic aborted error', async () => { + const instance = makeInstance({ LSP_FAKE_HANG: '1' }) + const controller = new AbortController() + const pending = run(instance, 'goToDefinition', controller.signal) + await new Promise(resolve => setTimeout(resolve, 200)) + controller.abort('a string reason, not an Error') + await expect(pending).rejects.toThrow(/aborted/) + }) +}) + +/** Probe a pid without changing its state. */ +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false + throw error + } +} + +/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */ +async function waitForFile(path: string, timeoutMs = 3000): Promise { + const started = Date.now() + for (;;) { + try { + await readFile(path) + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (Date.now() - started > timeoutMs) throw new Error('waitForFile timed out') + await new Promise(resolve => setTimeout(resolve, 10)) + } +} diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts new file mode 100644 index 0000000000..826905ed26 --- /dev/null +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -0,0 +1,319 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' +import { deadline } from '@deepseek-ai/dsh-timeout' +import * as LspLocal from '@deepseek-ai/dsh-lsp-local' +import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' + +const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) + +let root: string +let ws: string + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-local-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\nconst y = x\n') +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +/** One fake stdio server entry with optional behavior and host-bound overrides. */ +function fakeServer(fakeEnv: Record = {}, overrides: Partial = {}): LspLocalServerConfig { + return { + command: process.execPath, + args: [fixtureServer], + env: { ...fakeEnv }, + extensionToLanguage: { '.ts': 'typescript' }, + ...overrides, + } +} + +/** Mount the real seam + lsp-local plugin driving one fake server. */ +async function mount(fakeEnv: Record = {}, overrides: Partial = {}): Promise { + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + servers: { fake: fakeServer(fakeEnv, overrides) }, + }) + return ctx +} + +function query(operation: LspQueryRequest['operation'], filePath = 'a.ts'): LspQueryRequest { + return { operation, filePath, position: { line: 0, character: 6 }, workspaceRoot: ws } +} + +/** A single Location JSON pointing into the workspace. */ +function locationJson(line: number): unknown { + return { uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line, character: 0 }, end: { line, character: 3 } } } +} + +describe('lsp-local end to end over a fake server', () => { + it('routes different extensions to independent configured servers', async () => { + await writeFile(join(ws, 'a.py'), 'x = 1\n') + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + servers: { + typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }), + python: fakeServer( + { LSP_FAKE_HOVER: JSON.stringify({ contents: 'py' }) }, + { extensionToLanguage: { '.py': 'python' } }, + ), + }, + }) + expect(await ctx.lsp.query(query('hover', 'a.ts'))).toEqual({ kind: 'hover', hover: { contents: 'ts' } }) + expect(await ctx.lsp.query(query('hover', 'a.py'))).toEqual({ kind: 'hover', hover: { contents: 'py' } }) + await ctx.fiber.dispose() + }) + + it('resolves definition to normalized locations', async () => { + const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + const result = await ctx.lsp.query(query('goToDefinition')) + expect(result).toEqual({ + kind: 'locations', + locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }], + resolvedWorkspaceRoot: ws, + }) + await ctx.fiber.dispose() + }) + + it('maps a LocationLink for implementation', async () => { + const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } } + const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) }) + const result = await ctx.lsp.query(query('goToImplementation')) + expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] }) + await ctx.fiber.dispose() + }) + + it('returns references (server includes the declaration)', async () => { + const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) }) + const result = await ctx.lsp.query(query('findReferences')) + expect(result).toMatchObject({ kind: 'locations' }) + if (result.kind !== 'locations') throw new Error('expected locations') + expect(result.locations).toHaveLength(2) + await ctx.fiber.dispose() + }) + + it('normalizes a hover MarkupContent', async () => { + const ctx = await mount({ LSP_FAKE_HOVER: JSON.stringify({ contents: { kind: 'markdown', value: 'docs' } }) }) + const result = await ctx.lsp.query(query('hover')) + expect(result).toEqual({ kind: 'hover', hover: { contents: 'docs' } }) + await ctx.fiber.dispose() + }) + + it('returns an empty locations result for a null definition', async () => { + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + await ctx.fiber.dispose() + }) + + it('returns a null hover for a null result', async () => { + const ctx = await mount({ LSP_FAKE_HOVER: 'null' }) + expect(await ctx.lsp.query(query('hover'))).toEqual({ kind: 'hover', hover: null }) + await ctx.fiber.dispose() + }) + + it('rejects a non-utf-16 position encoding at initialize', async () => { + const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) + await ctx.fiber.dispose() + }) + + it('does not pool a poisoned instance when initialize rejects', async () => { + // A utf-8 server makes `initialize` reject; the instance must be torn down (not left with a + // permanently-rejecting `ready`) so a later query starts a fresh process rather than reusing it. + const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) + // A second query must also fail the same way (fresh instance), and must NOT hang on a poisoned one. + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) + await ctx.fiber.dispose() + }) + + it('rejects a server without transient-open sync (None)', async () => { + const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/transient textDocument\/didOpen/) + await ctx.fiber.dispose() + }) + + it('accepts openClose options sync', async () => { + const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' }) + expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + await ctx.fiber.dispose() + }) + + it('fails a query for an unsupported operation', async () => { + const ctx = await mount({ LSP_FAKE_CAPS: JSON.stringify({ hoverProvider: false }), LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query(query('hover'))).rejects.toThrow(/does not support hover/) + await ctx.fiber.dispose() + }) + + it('rejects a source outside the workspace before startup', async () => { + const outside = join(root, 'out.ts') + await writeFile(outside, 'x') + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toThrow(/outside the workspace/) + await ctx.fiber.dispose() + }) + + it('serializes queries through one instance and runs them in order', async () => { + const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + const results = await Promise.all([ + ctx.lsp.query(query('goToDefinition')), + ctx.lsp.query(query('goToDefinition')), + ctx.lsp.query(query('goToDefinition')), + ]) + for (const result of results) expect(result).toMatchObject({ kind: 'locations' }) + await ctx.fiber.dispose() + }) + + it('reads a queued query source only when its lifecycle starts', async () => { + const marker = join(root, 'opened.jsonl') + const ctx = await mount({ + LSP_FAKE_DEF: 'null', + LSP_FAKE_REPLY_DELAY_MS: '300', + LSP_FAKE_OPEN_MARKER: marker, + }) + const first = ctx.lsp.query(query('goToDefinition')) + await waitFor(async () => (await markerLines(marker)).length === 1) + const second = ctx.lsp.query(query('goToDefinition')) + await writeFile(join(ws, 'a.ts'), 'const changed = 2\n') + await Promise.all([first, second]) + expect(await markerLines(marker)).toEqual([ + 'const x = 1\nconst y = x\n', + 'const changed = 2\n', + ]) + await ctx.fiber.dispose() + }) + + it('aborts an in-flight query when the signal fires', async () => { + const ctx = await mount({ LSP_FAKE_HANG: '1' }) + const controller = new AbortController() + const pending = ctx.lsp.query(query('goToDefinition'), controller.signal) + controller.abort(new Error('caller cancelled')) + await expect(pending).rejects.toThrow(/cancelled/) + await ctx.fiber.dispose() + }) + + it('honors an already-aborted signal before any host I/O or startup', async () => { + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + const controller = new AbortController() + controller.abort(new Error('pre-aborted')) + await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toThrow(/pre-aborted/) + await ctx.fiber.dispose() + }) + + it('surfaces the server stderr tail in the exit error', async () => { + // A server that writes to stderr then exits without answering: the query rejection carries the + // retained stderr tail so the failure is diagnosable. + const ctx = await mount({}, { + command: process.execPath, + args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'], + }) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/FATAL: boom/) + await ctx.fiber.dispose() + }) + + it('classifies a timeout deadline as the abort reason', async () => { + const ctx = await mount({ LSP_FAKE_HANG: '1' }) + using d = deadline(undefined, 50, 'TEST_TIMEOUT') + await expect(ctx.lsp.query(query('goToDefinition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/) + await ctx.fiber.dispose() + }) + + it('fails the active query when the server crashes on open, and replaces it next query', async () => { + const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow() + // A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang). + await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow() + await ctx.fiber.dispose() + }) + + it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => { + // The first query succeeds, then the server exits before the second arrives, leaving a dead + // instance in the pool. The next query must evict-and-replace it and still succeed, rather than + // failing once on the closed connection first. + const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' }) + // Wait past the fixture's post-reply exit so the pooled instance is observably dead. + await new Promise(resolve => setTimeout(resolve, 60)) + expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' }) + await ctx.fiber.dispose() + }) + + it('does not spawn a server when the signal aborts during source read', async () => { + // Abort right after issuing the query: the abort lands while canonicalizeWorkspace/readHostSource + // are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance. + const ctx = await mount({ LSP_FAKE_DEF: 'null' }) + const controller = new AbortController() + const pending = ctx.lsp.query(query('goToDefinition'), controller.signal) + controller.abort(new Error('mid-read cancel')) + await expect(pending).rejects.toThrow(/mid-read cancel/) + // A subsequent live query still works, proving no half-created instance poisoned the pool. + expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws }) + await ctx.fiber.dispose() + }) + + it('runs distinct workspaces in parallel instances', async () => { + const ws2 = join(root, 'ws2') + await mkdir(ws2) + await writeFile(join(ws2, 'a.ts'), 'const z = 2\n') + const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) + const [r1, r2] = await Promise.all([ + ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws }), + ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws2 }), + ]) + expect(r1).toMatchObject({ kind: 'locations' }) + expect(r2).toMatchObject({ kind: 'locations' }) + await ctx.fiber.dispose() + }) + + it('disposes cleanly, terminating a server that ignores shutdown', async () => { + const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 }) + await ctx.lsp.query(query('goToDefinition')) + await expect(ctx.fiber.dispose()).resolves.toBeUndefined() + }) + + it('rejects at load when the command is not found', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + servers: { + missing: { + command: 'definitely-not-a-real-lsp-binary-xyz', + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + }, + }, + })).rejects.toThrow(/was not found on PATH/) + await ctx.fiber.dispose() + }) +}) + +/** Read the fixture's JSON-lines didOpen marker, returning no entries before it exists. */ +async function markerLines(path: string): Promise { + try { + const text = await readFile(path, 'utf8') + return text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as string) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } +} + +/** Poll an asynchronous condition until it succeeds or the test-local deadline expires. */ +async function waitFor(condition: () => Promise, timeoutMs = 3000): Promise { + const started = Date.now() + while (!await condition()) { + if (Date.now() - started > timeoutMs) throw new Error('waitFor timed out') + await new Promise(resolve => setTimeout(resolve, 10)) + } +} diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts new file mode 100644 index 0000000000..8746b7a903 --- /dev/null +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' +import * as LspLocal from '@deepseek-ai/dsh-lsp-local' +import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +let root: string +let ws: string + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-prov-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +function query(): LspQueryRequest { + return { operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws } +} + +/** Wrap one server entry in the plugin's named server table. */ +function config(providerId: string, server: LspLocalServerConfig): Config { + return { servers: { [providerId]: server } } +} + +describe('lsp-local provider resolution', () => { + it('resolves a bare command on the child PATH and registers the provider', async () => { + // A tiny executable script placed on a custom PATH dir: the load-time resolver must find it. + const bin = join(root, 'bin') + await mkdir(bin) + const exe = join(bin, 'fake-lsp') + await writeFile(exe, '#!/bin/sh\nexit 0\n') + await chmod(exe, 0o755) + + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('onpath', { + command: 'fake-lsp', + args: [], + env: { PATH: bin }, + extensionToLanguage: { '.ts': 'typescript' }, + }))).resolves.toBeDefined() + await ctx.fiber.dispose() + }) + + it('skips empty PATH segments and fails when the command is absent', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('nope', { + command: 'fake-lsp', + args: [], + env: { PATH: `::${join(root, 'empty')}` }, + extensionToLanguage: { '.ts': 'typescript' }, + }))).rejects.toThrow(/was not found on PATH/) + await ctx.fiber.dispose() + }) + + it('rejects a query after the provider is disposed', async () => { + // Use a server that never emits results and dispose the plugin, then confirm queries are refused. + const ctx = new Context() + await ctx.plugin(Lsp) + // Grab the provider instance by registering, then dispose the whole plugin fiber. + const lsp = ctx.lsp + const fiber = await ctx.plugin(LspLocal, config('disp', { + command: process.execPath, + args: ['-e', 'setInterval(()=>{},1000)'], + extensionToLanguage: { '.ts': 'typescript' }, + })) + await fiber.dispose() + // After disposal the provider unregistered from the seam, so selection fails as unavailable. + await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + await ctx.fiber.dispose() + }) + + it('rejects a nonpositive teardown budget at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('bad-budget', { + command: process.execPath, + args: ['-e', ''], + extensionToLanguage: { '.ts': 'typescript' }, + killGraceMs: 0, + }))).rejects.toThrow(/servers\.bad-budget\.killGraceMs must be a positive integer/) + await ctx.fiber.dispose() + }) + + it('rejects a nonpositive byte cap at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('bad-cap', { + command: process.execPath, + args: ['-e', ''], + extensionToLanguage: { '.ts': 'typescript' }, + maxDocumentBytes: 0, + }))).rejects.toThrow(/servers\.bad-cap\.maxDocumentBytes must be a positive integer/) + await ctx.fiber.dispose() + }) + + it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('bad-timer', { + command: process.execPath, + args: ['-e', ''], + extensionToLanguage: { '.ts': 'typescript' }, + [name]: MAX_TIMER_DELAY_MS + 1, + }))).rejects.toThrow(new RegExp(`servers\\.bad-timer\\.${name}`)) + await ctx.fiber.dispose() + }) + + it('rejects an absolute command that is not executable at load', async () => { + const notExe = join(root, 'not-exe.txt') + await writeFile(notExe, 'plain text, not executable') + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('abs-bad', { + command: notExe, + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + }))).rejects.toThrow(/is not an executable file/) + await ctx.fiber.dispose() + }) + + it('rejects an executable directory as a command at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('abs-directory', { + command: ws, + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + }))).rejects.toThrow(/is not an executable file/) + await ctx.fiber.dispose() + }) + + it('rejects an empty server table at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/) + await ctx.fiber.dispose() + }) + + it('rejects an empty server id at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('', { + command: process.execPath, + extensionToLanguage: { '.ts': 'typescript' }, + }))).rejects.toThrow(/server ids must be non-empty strings/) + await ctx.fiber.dispose() + }) + + it('resolves every executable before publishing any provider', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + servers: { + valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + missing: { command: 'definitely-not-a-real-lsp-binary-xyz', extensionToLanguage: { '.py': 'python' } }, + }, + })).rejects.toThrow(/was not found on PATH/) + await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + await ctx.fiber.dispose() + }) + + it('rolls back earlier registrations when a later server conflicts', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + servers: { + first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + second: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + }, + })).rejects.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/lsp/lsp-local/tests/translate.spec.ts b/packages/lsp/lsp-local/tests/translate.spec.ts new file mode 100644 index 0000000000..a68afebdd5 --- /dev/null +++ b/packages/lsp/lsp-local/tests/translate.spec.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' +import { + negotiatePositionEncoding, + normalizeHover, + normalizeLocations, + requestMethod, + supportsOperation, + supportsTransientOpen, +} from '@deepseek-ai/dsh-lsp-local' +import type { WireServerCapabilities } from '@deepseek-ai/dsh-lsp-local/src/protocol.ts' + +const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } } + +describe('requestMethod', () => { + it('maps each operation to its textDocument request', () => { + expect(requestMethod('goToDefinition')).toBe('textDocument/definition') + expect(requestMethod('findReferences')).toBe('textDocument/references') + expect(requestMethod('goToImplementation')).toBe('textDocument/implementation') + expect(requestMethod('hover')).toBe('textDocument/hover') + }) +}) + +describe('supportsOperation', () => { + it('reads the provider slot for each operation (boolean and options forms)', () => { + const caps: WireServerCapabilities = { + definitionProvider: true, + referencesProvider: { workDoneProgress: true }, + implementationProvider: false, + } + expect(supportsOperation(caps, 'goToDefinition')).toBe(true) + expect(supportsOperation(caps, 'findReferences')).toBe(true) + expect(supportsOperation(caps, 'goToImplementation')).toBe(false) + expect(supportsOperation(caps, 'hover')).toBe(false) + }) +}) + +describe('supportsTransientOpen', () => { + it('accepts legacy Full and Incremental enums, rejects None and absent', () => { + expect(supportsTransientOpen(1)).toBe(true) + expect(supportsTransientOpen(2)).toBe(true) + expect(supportsTransientOpen(0)).toBe(false) + expect(supportsTransientOpen(undefined)).toBe(false) + }) + + it('accepts options with openClose:true and rejects openClose:false', () => { + expect(supportsTransientOpen({ openClose: true })).toBe(true) + expect(supportsTransientOpen({ openClose: false, change: 2 })).toBe(false) + }) + + it('requires an explicit openClose for the options form (no change-enum fallback)', () => { + expect(supportsTransientOpen({ change: 1 })).toBe(false) + expect(supportsTransientOpen({ change: 2 })).toBe(false) + expect(supportsTransientOpen({})).toBe(false) + }) +}) + +describe('negotiatePositionEncoding', () => { + it('defaults an omitted encoding to utf-16', () => { + expect(negotiatePositionEncoding(undefined)).toBe('utf-16') + expect(negotiatePositionEncoding('utf-16')).toBe('utf-16') + }) + + it('rejects any other encoding', () => { + expect(() => negotiatePositionEncoding('utf-8')).toThrow(/unsupported position encoding/) + }) +}) + +describe('normalizeLocations', () => { + it('returns empty only for the protocol no-result value null', () => { + expect(normalizeLocations(null)).toEqual([]) + expect(() => normalizeLocations(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) + + it('maps a single Location', () => { + expect(normalizeLocations({ uri: 'file:///a', range: RANGE })).toEqual([{ uri: 'file:///a', range: RANGE }]) + }) + + it('maps an array of Locations', () => { + const result = normalizeLocations([{ uri: 'file:///a', range: RANGE }, { uri: 'file:///b', range: RANGE }]) + expect(result.map(l => l.uri)).toEqual(['file:///a', 'file:///b']) + }) + + it('maps a LocationLink from targetUri + targetSelectionRange', () => { + const link = { targetUri: 'file:///c', targetSelectionRange: RANGE, targetRange: RANGE } + expect(normalizeLocations([link])).toEqual([{ uri: 'file:///c', range: RANGE }]) + }) + + it('rejects a non-object entry', () => { + expect(() => normalizeLocations([42])).toThrow(/non-object/) + }) + + it('rejects an entry that is neither a Location nor a LocationLink', () => { + expect(() => normalizeLocations([{ nope: true }])).toThrow(/neither a Location nor a LocationLink/) + }) + + it('rejects a Location whose range is not an object', () => { + expect(() => normalizeLocations([{ uri: 'file:///a', range: 'nope' }])).toThrow(/neither a Location/) + }) + + it('rejects a Location whose range positions are malformed', () => { + expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/) + }) + + it('rejects negative and fractional position coordinates', () => { + expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: { line: -1, character: 0 }, end: RANGE.end } }])) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: RANGE.start, end: { line: 1.5, character: 5 } } }])) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) +}) + +describe('normalizeHover', () => { + it('returns null for null', () => { + expect(normalizeHover(null)).toBeNull() + }) + + it('rejects a missing hover result', () => { + expect(() => normalizeHover(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) + + it('reads MarkupContent value and keeps a range', () => { + expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE })) + .toEqual({ contents: '# H', range: RANGE }) + }) + + it('keeps a bare string MarkedString verbatim', () => { + expect(normalizeHover({ contents: 'plain text' })).toEqual({ contents: 'plain text' }) + }) + + it('renders a language-tagged MarkedString object as a fenced code block', () => { + expect(normalizeHover({ contents: { language: 'ts', value: 'const x = 1' } })) + .toEqual({ contents: '```ts\nconst x = 1\n```' }) + }) + + it('joins a MarkedString array with one blank line', () => { + expect(normalizeHover({ contents: ['a', { language: 'ts', value: 'b' }] })) + .toEqual({ contents: 'a\n\n```ts\nb\n```' }) + }) + + it('drops an empty-contents hover to null', () => { + expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).toBeNull() + }) + + it('rejects a MarkupContent with a non-string value', () => { + expect(() => normalizeHover({ contents: { kind: 'markdown', value: 42 } })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) + + it('rejects a non-object payload', () => { + expect(() => normalizeHover(42)).toThrow(/was not an object/) + }) + + it('rejects malformed contents', () => { + expect(() => normalizeHover({ contents: { weird: true } })).toThrow(/were not MarkupContent/) + expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/) + }) + + it('rejects a malformed MarkedString array member', () => { + expect(() => normalizeHover({ contents: ['ok', { language: 'ts', value: 42 }] })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + expect(() => normalizeHover({ contents: [null] })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) + + it('rejects a hover with no contents field', () => { + expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/) + }) + + it('rejects a malformed range instead of silently dropping it', () => { + expect(() => normalizeHover({ contents: 'x', range: { start: { line: 1 } } })) + .toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' })) + }) +}) diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts new file mode 100644 index 0000000000..8ba61c0717 --- /dev/null +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -0,0 +1,114 @@ +/** + * Keyless real-server e2e: drives the real `typescript-language-server` through the full + * `ctx.lsp` → `dsh-lsp-local` stack over the base protocol, exercising all four operations. No API + * key needed — the server is a local dev dependency. This establishes one compatibility floor + * (TypeScript), not a cross-language claim. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' +import * as LspLocal from '@deepseek-ai/dsh-lsp-local' + +// The server binary is a dev dependency of this package; resolve its pnpm-hoisted .bin path. +const serverBin = join( + new URL('..', import.meta.url).pathname, + 'node_modules', + '.bin', + 'typescript-language-server', +) + +let root: string +let ws: string +let ctx: Context + +beforeAll(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-ts-e2e-'))) + ws = join(root, 'proj') + await mkdir(ws) + await writeFile(join(ws, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, module: 'nodenext' } })) + // A small program with a definition, a reference, an interface + implementation, and a typed value. + await writeFile(join(ws, 'shapes.ts'), [ + 'export interface Shape {', + ' area(): number', + '}', + '', + 'export class Circle implements Shape {', + ' constructor(private r: number) {}', + ' area(): number { return Math.PI * this.r * this.r }', + '}', + '', + 'export function describe(s: Shape): string {', + ' return `area=${s.area()}`', + '}', + '', + 'const c = new Circle(2)', + 'export const text = describe(c)', + '', + ].join('\n')) + + ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + servers: { + typescript: { + command: serverBin, + args: ['--stdio'], + extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' }, + }, + }, + }) +}, 60_000) + +afterAll(async () => { + if (ctx) await ctx.fiber.dispose() + if (root) await rm(root, { recursive: true, force: true }) +}) + +/** One-based helper mirroring the model contract, converted to the seam's zero-based position. */ +function at(operation: LspQueryRequest['operation'], line1: number, char1: number, filePath = 'shapes.ts'): LspQueryRequest { + return { operation, filePath, position: { line: line1 - 1, character: char1 - 1 }, workspaceRoot: ws } +} + +function locations(result: LspQueryResult): readonly { uri: string }[] { + if (result.kind !== 'locations') throw new Error(`expected locations, got ${result.kind}`) + return result.locations +} + +describe('real typescript-language-server', () => { + it('resolves the definition of a call site to its declaration', async () => { + // `export const text = describe(c)` (line 15): `describe` begins at column 21. + const result = await ctx.lsp.query(at('goToDefinition', 15, 22)) + const locs = locations(result) + expect(locs.length).toBeGreaterThanOrEqual(1) + expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true) + }, 60_000) + + it('finds references to a symbol including its declaration', async () => { + // References to `describe` from its declaration (line 10, col 17). + const result = await ctx.lsp.query(at('findReferences', 10, 17)) + const locs = locations(result) + // At least the declaration plus the call site. + expect(locs.length).toBeGreaterThanOrEqual(2) + }, 60_000) + + it('resolves implementations of an interface', async () => { + // Implementations of `Shape` (line 1, col 18) → Circle. + const result = await ctx.lsp.query(at('goToImplementation', 1, 18)) + const locs = locations(result) + expect(locs.length).toBeGreaterThanOrEqual(1) + }, 60_000) + + it('returns hover information for a typed symbol', async () => { + // Hover on `Circle` in `new Circle(2)` (line 14, col 15). + const result = await ctx.lsp.query(at('hover', 14, 15)) + expect(result.kind).toBe('hover') + if (result.kind === 'hover') { + expect(result.hover).not.toBeNull() + expect(result.hover?.contents).toContain('Circle') + } + }, 60_000) +}) diff --git a/packages/lsp/lsp-local/tsconfig.json b/packages/lsp/lsp-local/tsconfig.json new file mode 100644 index 0000000000..3a631ae288 --- /dev/null +++ b/packages/lsp/lsp-local/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../lsp" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md new file mode 100644 index 0000000000..df9ced7dc3 --- /dev/null +++ b/packages/lsp/lsp/README.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-lsp + +The **LSP capability seam**: an abstract `LspService` (`ctx.lsp`) defining WHAT semantic code navigation the harness has — go to definition, find references, find implementations, hover — over language-server providers, without binding the model contract to local subprocesses. + +This package is the interface third of the LSP capability: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-lsp` (this) | the interface: the service, provider registry keyed by branded id + extension mapping, per-query selection, request/result vocabulary, the `LspError` taxonomy | +| `@deepseek-ai/dsh-lsp-local` | a generic local backend that registers configured stdio language-server providers | +| `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` | + +The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`. + +## Service API (`ctx.lsp`) + +| Member | Semantics | +|---|---| +| `registerProvider(provider)` | Register a backend, atomically reserving its branded `id` and every normalized file extension. Any invalid input or conflict publishes nothing and throws `LspError` (`LSP_INVALID_PROVIDER` / `LSP_CONFLICT`). Returns a disposer releasing all reservations. Disposed with the calling fiber. | +| `query(request, signal?)` | Select the provider by the file's final extension, derive the `languageId` from that provider's mapping, and run one query. No match throws `LspError` `LSP_UNAVAILABLE`. | + +Selection is per query and order-independent: a provider owns a set of extensions exclusively, so registration and HMR order never change routing. Extension keys normalize to lowercase, leading-dot form; the `languageId` only synchronizes the transient document, never participates in selection. The first version has no glob, language-id, or explicit route selector. + +Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner of the model-facing name, description, prompt guidance, schema, and presentation. + +## Vocabulary + +`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `findReferences` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`. + +## Model Experience + +Indirectly, through `dsh-tool-lsp`, which owns the model-facing `lsp` schema, prompt, and rendered results while this registry contributes no prompt or schema itself. + +#### KV Cache effect + +No direct invalidation; `dsh-tool-lsp` owns request-prefix changes. + +## Known Limitations and Deferred Work + +- **Exclusive extension ownership within one runtime** — two providers cannot both claim `.ts`, even with different language ids; overlaps fail registration. The intended extension is a deployment-configured selector above registrations, which can relax exclusive reservation without adding provider choice to model input ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). +- **Four operations only** — symbols and call hierarchy are deferred (they need different schemas); diagnostics need separate freshness/accumulation rules; mutations (rename, code actions, formatting) require separate tools with preview, permission, and write-policy integration. +- **No observation surface** — availability is observed only by running `query()` and routing the thrown `LspError` codes; there is no provider-change event or capability-status query. diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json new file mode 100644 index 0000000000..6a96dfdf70 --- /dev/null +++ b/packages/lsp/lsp/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-lsp", + "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "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-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/lsp/lsp/src/brand.ts b/packages/lsp/lsp/src/brand.ts new file mode 100644 index 0000000000..fe51a1ea00 --- /dev/null +++ b/packages/lsp/lsp/src/brand.ts @@ -0,0 +1,21 @@ +/** + * dsh-lsp's owned branded id: {@link LspProviderId}, the opaque identity a provider reserves on + * `ctx.lsp`. The `Branded` primitive lives in `@deepseek-ai/dsh-brand`; keeping the type and its + * factory together here lets `index.ts` re-export both under one name. + * @module @deepseek-ai/dsh-lsp/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Opaque provider identity, reserved atomically with its extension mappings at registration. */ +export type LspProviderId = Branded<'LspProviderId'> + +/** + * Brand a string as an {@link LspProviderId}. No validation — the registry rejects an empty id at + * registration. + * @param id - the provider's stable identifier. + * @returns the same string, branded. + */ +export function LspProviderId(id: string): LspProviderId { + return id as LspProviderId +} diff --git a/packages/lsp/lsp/src/index.ts b/packages/lsp/lsp/src/index.ts new file mode 100644 index 0000000000..d7b1a80d01 --- /dev/null +++ b/packages/lsp/lsp/src/index.ts @@ -0,0 +1,158 @@ +/** + * The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query, + * order-independent selection over normalized goToDefinition/findReferences/goToImplementation/ + * hover queries. + * + * A provider reserves a branded id and an exclusive set of file extensions atomically: + * {@link Lsp.registerProvider} validates and conflict-checks everything before mutating, so an + * invalid or conflicting registration publishes nothing, and its disposer releases every + * reservation together. Selection routes a query by the file's final extension; it never depends on + * registration order. The seam exposes exactly the four operations and no JSON-RPC escape hatch. + * @module @deepseek-ai/dsh-lsp + */ + +import { Context, Service } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { LspProviderId } from './brand.ts' +import type { + LspProvider, + LspQueryRequest, + LspQueryResult, + LspService, +} from './types.ts' + +export { LspProviderId } from './brand.ts' +export type { + LspHover, + LspLocation, + LspOperation, + LspPosition, + LspProvider, + LspProviderQuery, + LspQueryRequest, + LspQueryResult, + LspRange, + LspService, +} from './types.ts' + +declare module 'cordis' { + interface Context { + lsp: LspService + } +} + +/** + * Structured LSP failure. Extends {@link HarnessError} with a stable `code` + * (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, + * `LSP_UNSUPPORTED_OPERATION`, `LSP_MALFORMED_RESPONSE`, …) that callers route on instead of + * parsing `message`. + */ +export class LspError extends HarnessError {} + +/** + * Extract a file's final extension as a normalized, lowercase, leading-dot key (e.g. `Foo.TS` → + * `.ts`, `foo.d.ts` → `.ts`). Returns `''` for a name with no extension or a leading-dot dotfile + * (`.bashrc`), which no route ever matches. Splits on both `/` and `\` so a caller's path separator + * does not change the result. + * @param filePath - the source path to inspect. + * @returns the normalized extension, or `''` when there is none. + */ +export function finalExtension(filePath: string): string { + const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) + const base = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath + const dot = base.lastIndexOf('.') + // dot <= 0 covers both "no dot" (-1) and a leading-dot dotfile (0): neither has an extension. + if (dot <= 0) return '' + return base.slice(dot).toLowerCase() +} + +/** A well-formed normalized extension: a dot followed by one or more non-dot, non-separator chars. */ +const EXTENSION_PATTERN = /^\.[^./\\]+$/ + +/** One selection route: the provider to run plus the language id to synchronize the document with. */ +interface Route { + readonly provider: LspProvider + readonly languageId: string +} + +/** + * `ctx.lsp`. Holds the id reservations and the extension→route table; both are populated and cleared + * together per provider so a route always has a live provider. + */ +export class Lsp extends Service implements LspService { + private readonly providerIds = new Set() + private readonly routes = new Map() + + constructor(ctx: Context) { + super(ctx, 'lsp') + } + + registerProvider(provider: LspProvider): () => void { + // Validate and conflict-check everything BEFORE any mutation: an invalid or conflicting + // registration must publish nothing (fail-loud, all-or-nothing). + const id = provider.id + if (id.trim() === '') { + throw new LspError('an LSP provider id must be a non-empty string', 'LSP_INVALID_PROVIDER') + } + if (this.providerIds.has(id)) { + throw new LspError(`an LSP provider with id "${id}" is already registered`, 'LSP_CONFLICT') + } + + const entries = Object.entries(provider.extensionToLanguage) + if (entries.length === 0) { + throw new LspError(`LSP provider "${id}" registers no file extensions`, 'LSP_INVALID_PROVIDER') + } + + // Normalize into this provider's route set, catching intra-provider duplicates (e.g. `.TS` and + // `.ts`) before checking cross-provider conflicts. + const pending = new Map() + for (const [rawExt, languageId] of entries) { + const ext = normalizeExtension(rawExt) + if (!EXTENSION_PATTERN.test(ext)) { + throw new LspError(`LSP provider "${id}" maps an invalid extension "${rawExt}"`, 'LSP_INVALID_PROVIDER') + } + if (languageId.trim() === '') { + throw new LspError(`LSP provider "${id}" maps extension "${ext}" to an empty language id`, 'LSP_INVALID_PROVIDER') + } + if (pending.has(ext)) { + throw new LspError(`LSP provider "${id}" maps extension "${ext}" more than once`, 'LSP_INVALID_PROVIDER') + } + pending.set(ext, { provider, languageId }) + } + for (const ext of pending.keys()) { + if (this.routes.has(ext)) { + throw new LspError(`extension "${ext}" is already handled by another LSP provider`, 'LSP_CONFLICT') + } + } + + // All checks passed: reserve id and every extension in one lifecycle controller so disposal + // releases them together. + const dispose = this.ctx.effect(function* (this: Lsp) { + this.providerIds.add(id) + for (const [ext, route] of pending) this.routes.set(ext, route) + yield () => { + this.providerIds.delete(id) + for (const ext of pending.keys()) this.routes.delete(ext) + } + }.bind(this), 'lsp.registerProvider()') + // ctx.effect's disposer returns Promise; our disposer API is synchronous + // fire-and-forget — discard the (always-resolved) promise. + return () => void dispose() + } + + async query(request: LspQueryRequest, signal?: AbortSignal): Promise { + const route = this.routes.get(finalExtension(request.filePath)) + if (route === undefined) { + throw new LspError(`no LSP provider handles "${request.filePath}"`, 'LSP_UNAVAILABLE') + } + return route.provider.query({ ...request, languageId: route.languageId }, signal) + } +} + +/** Lowercase an extension and ensure it carries a leading dot; `EXTENSION_PATTERN` rejects the rest. */ +function normalizeExtension(ext: string): string { + const lower = ext.toLowerCase() + return lower.startsWith('.') ? lower : `.${lower}` +} + +export default Lsp diff --git a/packages/lsp/lsp/src/invariant.ts b/packages/lsp/lsp/src/invariant.ts new file mode 100644 index 0000000000..27481309f4 --- /dev/null +++ b/packages/lsp/lsp/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-lsp`. + * @module @deepseek-ai/dsh-lsp/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-lsp' + +/** Cordis companion plugin name. */ +export const name = 'lsp-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: provider ids and extension routes are private, atomically updated state; + * the seam exposes neither an enumerable snapshot nor lifecycle events to compare independently. + */ +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/lsp/lsp/src/types.ts b/packages/lsp/lsp/src/types.ts new file mode 100644 index 0000000000..d0c84f606d --- /dev/null +++ b/packages/lsp/lsp/src/types.ts @@ -0,0 +1,130 @@ +/** + * LSP seam vocabulary: the normalized request, provider, and result contracts. Types only — the + * {@link LspError} taxonomy and the {@link LspProviderId} brand factory are runtime and live in + * `index.ts`. Positions and ranges are zero-based UTF-16, matching the protocol; the model-facing + * tool owns the one-based cursor convention. The seam exposes no protocol types, process or document + * controls, or generic JSON-RPC escape hatch — only the four semantic operations. + * @module @deepseek-ai/dsh-lsp/types + */ + +import type { LspProviderId } from './brand.ts' + +/** + * The four semantic queries the seam and model expose. A closed union: adding an operation is a + * compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are + * deliberately deferred (they need different schemas). + */ +export type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover' + +/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */ +export interface LspPosition { + /** Zero-based line. */ + readonly line: number + /** Zero-based UTF-16 code-unit offset within the line. */ + readonly character: number +} + +/** A zero-based UTF-16 half-open range `[start, end)`. */ +export interface LspRange { + readonly start: LspPosition + readonly end: LspPosition +} + +/** + * A caller's normalized query. Every field is required: `workspaceRoot` is caller-supplied, + * `languageId` comes from the provider registration (not here), and consumers own timeouts and + * result limits — so no field needs implementation defaulting and there is no `resolve()` step. + */ +export interface LspQueryRequest { + /** Which semantic query to run. */ + readonly operation: LspOperation + /** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */ + readonly filePath: string + /** The zero-based UTF-16 cursor position to query at. */ + readonly position: LspPosition + /** The workspace root the provider resolves against and indexes; required, never defaulted. */ + readonly workspaceRoot: string +} + +/** + * A request as a provider receives it: the caller's {@link LspQueryRequest} plus the `languageId` + * the seam derived from the provider's extension mapping. The language id only synchronizes the + * transient document; it does not participate in selection. + */ +export interface LspProviderQuery extends LspQueryRequest { + /** The LSP language id for `filePath`, from this provider's extension mapping. */ + readonly languageId: string +} + +/** One resolved location: a document URI and the range within it. */ +export interface LspLocation { + /** The target document URI (`file:` or otherwise), verbatim from the server. */ + readonly uri: string + /** The range within the target document. */ + readonly range: LspRange +} + +/** Normalized hover content, or `null` for no hover at the position. */ +export interface LspHover { + /** The normalized hover text (markdown or plaintext, provider-joined). */ + readonly contents: string + /** The range the hover applies to, when the server supplied one. */ + readonly range?: LspRange +} + +/** + * The closed result union. Navigation operations (`goToDefinition`, `findReferences`, + * `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`. + * Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. + * + * The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the + * request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that + * relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`; + * otherwise a symlinked workspace misclassifies in-workspace results as external. + */ +export type LspQueryResult = + | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string } + | { readonly kind: 'hover'; readonly hover: LspHover | null } + +/** + * A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link + * LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). + * `findReferences` always includes declarations — the provider enforces this internally; callers + * get no flag. + */ +export interface LspProvider { + /** Stable provider identity, reserved atomically with the extension mappings. */ + readonly id: LspProviderId + /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ + readonly extensionToLanguage: Readonly> + /** + * Run one query. The seam has already selected this provider and derived `languageId`. + * @param request - the resolved provider query (caller request + derived language id). + * @param signal - optional cancellation; the provider stops its own work when it aborts. + * @returns the normalized, closed-union result. + */ + query(request: LspProviderQuery, signal?: AbortSignal): Promise +} + +/** + * The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query + * execution; exposes exactly the four operations and no protocol escape hatch. + */ +export interface LspService { + /** + * Register a provider, atomically reserving its id and every normalized extension. Any conflict + * or invalid input publishes nothing and throws `LspError`; the returned disposer releases all + * reservations. Disposed with the calling fiber. + * @param provider - the backend to register. + * @returns a synchronous disposer releasing the id and all extension reservations. + */ + registerProvider(provider: LspProvider): () => void + /** + * Select a provider by the file's extension and run one query. Selection is per-query and + * order-independent; no match throws `LspError` `LSP_UNAVAILABLE`. + * @param request - the normalized query. + * @param signal - optional cancellation forwarded to the selected provider. + * @returns the normalized, closed-union result. + */ + query(request: LspQueryRequest, signal?: AbortSignal): Promise +} diff --git a/packages/lsp/lsp/tests/lsp.spec.ts b/packages/lsp/lsp/tests/lsp.spec.ts new file mode 100644 index 0000000000..77ea9687c7 --- /dev/null +++ b/packages/lsp/lsp/tests/lsp.spec.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Lsp, { + finalExtension, + LspError, + LspProviderId, + type LspProvider, + type LspProviderQuery, + type LspQueryResult, +} from '@deepseek-ai/dsh-lsp' + +/** A scripted provider that records the queries it receives. */ +function makeProvider( + id: string, + extensionToLanguage: Record, + result: LspQueryResult = { kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }, +): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } { + const seen: LspProviderQuery[] = [] + const seenSignals: (AbortSignal | undefined)[] = [] + return { + id: LspProviderId(id), + extensionToLanguage, + seen, + seenSignals, + query(request, signal) { + seen.push(request) + seenSignals.push(signal) + return Promise.resolve(result) + }, + } +} + +/** Mount an Lsp service on a fresh root context. */ +async function mountLsp(): Promise<{ ctx: Context; lsp: Lsp }> { + const ctx = new Context() + await ctx.plugin(Lsp) + return { ctx, lsp: ctx.lsp as Lsp } +} + +const hover: LspQueryResult = { kind: 'hover', hover: { contents: 'x' } } + +function query(filePath: string, operation: LspProviderQuery['operation'] = 'goToDefinition'): Parameters[0] { + return { operation, filePath, position: { line: 0, character: 0 }, workspaceRoot: '/ws' } +} + +describe('finalExtension', () => { + it('lowercases and keeps only the final extension', () => { + expect(finalExtension('src/Foo.TS')).toBe('.ts') + expect(finalExtension('a/b/foo.d.ts')).toBe('.ts') + expect(finalExtension('C:\\proj\\Main.CS')).toBe('.cs') + }) + + it('returns empty for no extension or a leading-dot dotfile', () => { + expect(finalExtension('Makefile')).toBe('') + expect(finalExtension('.bashrc')).toBe('') + expect(finalExtension('dir.d/file')).toBe('') + }) +}) + +describe('Lsp registration', () => { + it('registers a provider and routes a query to it, then releases on dispose', async () => { + const { lsp } = await mountLsp() + const provider = makeProvider('ts', { '.ts': 'typescript' }) + const dispose = lsp.registerProvider(provider) + + await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }) + expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' }) + + dispose() + await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('normalizes extension keys to lowercase leading-dot and derives the language id', async () => { + const { lsp } = await mountLsp() + const provider = makeProvider('ts', { TS: 'typescript' }) + lsp.registerProvider(provider) + await lsp.query(query('a.ts')) + expect(provider.seen[0]?.languageId).toBe('typescript') + }) + + it('rejects an empty provider id (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider(' ', { '.ts': 'typescript' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects a provider with no extensions (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', {}))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects an invalid extension mapping (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', { '.tar.gz': 'archive' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects an empty language id (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': ' ' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects an extension mapped twice within one provider (LSP_INVALID_PROVIDER)', async () => { + const { lsp } = await mountLsp() + expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript', TS: 'ts2' }))) + .toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' })) + }) + + it('rejects a duplicate provider id (LSP_CONFLICT)', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + expect(() => lsp.registerProvider(makeProvider('ts', { '.tsx': 'typescriptreact' }))) + .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + }) + + it('rejects an extension already owned by another provider (LSP_CONFLICT)', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + expect(() => lsp.registerProvider(makeProvider('other', { '.ts': 'other-lang' }))) + .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + }) + + it('publishes nothing when a later extension conflicts (atomic reservation)', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + // This provider's `.py` is free but `.ts` conflicts: the whole registration must roll back. + expect(() => lsp.registerProvider(makeProvider('py-ts', { '.py': 'python', '.ts': 'x' }))) + .toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + // `.py` must NOT have been reserved. + await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('releases every extension and the id together on dispose', async () => { + const { lsp } = await mountLsp() + const dispose = lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript', '.tsx': 'typescriptreact' })) + dispose() + await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + await expect(lsp.query(query('a.tsx'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + // The id is free again after release. + expect(() => lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript' }))).not.toThrow() + }) + + it('selection is order-independent across two providers', async () => { + const { lsp } = await mountLsp() + const ts = makeProvider('ts', { '.ts': 'typescript' }, hover) + const py = makeProvider('py', { '.py': 'python' }) + lsp.registerProvider(ts) + lsp.registerProvider(py) + await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }) + await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover) + }) + + it('forwards the abort signal verbatim to the provider', async () => { + const { lsp } = await mountLsp() + const provider = makeProvider('ts', { '.ts': 'typescript' }) + lsp.registerProvider(provider) + const controller = new AbortController() + await lsp.query(query('a.ts'), controller.signal) + expect(provider.seenSignals[0]).toBe(controller.signal) + }) + + it('fails LSP_UNAVAILABLE when no provider handles the extension', async () => { + const { lsp } = await mountLsp() + lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => { + const { ctx, lsp } = await mountLsp() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) + }, { inject: ['lsp'] })) + await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' }) + await fiber.dispose() + await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + }) + + it('LspError carries its structured code', () => { + expect(new LspError('m', 'LSP_UNAVAILABLE').code).toBe('LSP_UNAVAILABLE') + }) + + it('brands a provider id without altering the string', () => { + expect(LspProviderId('ts')).toBe('ts') + }) +}) diff --git a/packages/lsp/lsp/tsconfig.json b/packages/lsp/lsp/tsconfig.json new file mode 100644 index 0000000000..14fcb74d1b --- /dev/null +++ b/packages/lsp/lsp/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md new file mode 100644 index 0000000000..4a844d2537 --- /dev/null +++ b/packages/lsp/tool-lsp/README.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-tool-lsp + +The model-facing **`lsp` tool** over `ctx.lsp`: one read-only tool with four operations for precise code navigation. It owns the model schema, prompt guidance, coordinate conversion, result limits and formatting, and ACP presentation; it imports no provider. + +Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). Injects `tools`, `lsp`, and `systemPrompt`. + +## The tool + +`lsp` accepts `operation` (`goToDefinition` | `findReferences` | `goToImplementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `findReferences` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input. + +The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Locations render as stable, file-grouped `path:line:character` entries relativized against the result's `resolvedWorkspaceRoot` (the provider's canonical root), not the session cwd — so a symlinked cwd still renders in-workspace results as workspace-relative paths; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `maxLocations` | `100` | Largest number of rendered locations before an omission marker. | +| `maxResultChars` | `16000` | Largest complete rendered result, including truncation metadata. | +| `timeoutMs` | `60000` | Tool-call timeout budget, enforced by `dsh-timeout-policy`; covers the complete queued open/query/close lifecycle and is not model-configurable. | + +## Model Experience + +### System prompt + +#### What the model sees + +One system-prompt section (order 112) positions LSP as a precision aid with the following text: + +##### Verbatim guidance + +```markdown +Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration. +``` + +#### Token effect + +Fixed guidance cost on every request while the plugin is active. + +#### KV Cache effect + +Prefix-stable while the plugin scope and guidance text are unchanged; activation or disposal may invalidate reuse from this section. + +### Tool schema + +#### What the model sees + +The model sees the generated [`lsp` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-lsp). + +#### Token effect + +Fixed schema cost on every request while enabled; the `timeoutMs` budget is never sent to the model. + +#### KV Cache effect + +Prefix-stable while the visible tool definition and order are unchanged; registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token. + +### Results + +#### What the model sees + +File-grouped `path:line:character` location lines or normalized hover text, capped first by `maxLocations` and then by `maxResultChars`; omission and truncation markers are included inside the complete character cap. Empty results use distinct `No results.` / `No hover information.` lines. + +#### Token effect + +Capped per tool result by `maxResultChars`, with `maxLocations` additionally bounding navigation item count. + +#### KV Cache effect + +Tool results append after the cached request prefix and do not directly invalidate it. + +### ACP presentation + +#### What the model sees + +Nothing. The client renders a generic search card — `{ card: 'generic', kind: 'search', title, locations: [{ path, line }] }` — whose args-derived title carries the operation and one-based cursor; follow-along focuses the queried line while the title preserves the column. + +#### Token effect + +Zero direct token effect because rendering is client-side only. + +#### KV Cache effect + +None; ACP presentation is outside the model request. + +## Known Limitations and Deferred Work + +- **UTF-16 cursor coordinates** — columns are exact for the protocol but hard for a model to count around non-BMP characters; an off-symbol position may return empty results, so the prompt explains the convention without encouraging broad LSP use ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). +- **No cross-server completeness promise** — supported servers may return empty or partial results depending on indexing readiness; the tool promises no completeness across languages or servers. diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json new file mode 100644 index 0000000000..febc303a48 --- /dev/null +++ b/packages/lsp/tool-lsp/package.json @@ -0,0 +1,54 @@ +{ + "name": "@deepseek-ai/dsh-tool-lsp", + "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "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-lsp": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-lsp-local": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/lsp/tool-lsp/src/index.ts b/packages/lsp/tool-lsp/src/index.ts new file mode 100644 index 0000000000..c298bdffb8 --- /dev/null +++ b/packages/lsp/tool-lsp/src/index.ts @@ -0,0 +1,145 @@ +/** + * Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations + * (`goToDefinition`/`findReferences`/`goToImplementation`/`hover`); it converts one-based UTF-16 + * cursor coordinates to the seam's zero-based positions, requires the session workspace with no + * fallback, caps and renders results, and attaches a configurable timeout budget for + * `dsh-timeout-policy` to enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and + * imports no provider. + * + * Namespace plugin (named exports, no default export). + * @module @deepseek-ai/dsh-tool-lsp + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { assertNever, type ContentBlock } from '@deepseek-ai/dsh-llm' +import { LspError } from '@deepseek-ai/dsh-lsp' +import type {} from '@deepseek-ai/dsh-lsp' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { + DEFAULT_MAX_LOCATIONS, + DEFAULT_MAX_RESULT_CHARS, + formatHover, + formatLocations, + LSP_OPERATIONS, + parseLspArgs, + presentLspCall, +} from './render.ts' +import { sessionCwd } from './session-cwd.ts' + +export { + DEFAULT_MAX_LOCATIONS, + DEFAULT_MAX_RESULT_CHARS, + formatHover, + formatLocations, + LSP_OPERATIONS, + parseLspArgs, + presentLspCall, + renderUri, +} from './render.ts' +export { sessionCwd } from './session-cwd.ts' + +/** Cordis plugin name for loader diagnostics. */ +export const name = 'tool-lsp' + +/** Services required by this plugin. */ +export const inject = ['tools', 'lsp', 'systemPrompt'] + +/** Default tool-call timeout budget (ms), covering the queued open/query/close lifecycle. */ +export const DEFAULT_LSP_TOOL_TIMEOUT_MS = 60_000 + +/** The stable system-prompt guidance positioning LSP as a precision aid. */ +export const LSP_PROMPT_TEXT = + 'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration.' + +/** Plugin configuration: result caps and the timeout budget. */ +export interface Config { + /** Largest number of rendered locations before an omission marker (default 100). */ + maxLocations?: number + /** Largest complete rendered result in characters, including truncation metadata (default 16000). */ + maxResultChars?: number + /** Tool-call timeout budget in ms (default 60000). */ + timeoutMs?: number +} + +export const Config: z = z.object({ + maxLocations: z.number().default(DEFAULT_MAX_LOCATIONS), + maxResultChars: z.number().default(DEFAULT_MAX_RESULT_CHARS), + timeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_LSP_TOOL_TIMEOUT_MS), +}) + +type ResolvedConfig = Required + +/** + * Register the `lsp` tool and its system-prompt guidance. + * @param ctx - the plugin context (must inject `tools`, `lsp`, `systemPrompt`). + * @param config - the resolved plugin configuration. + */ +export function apply(ctx: Context, config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveInteger('maxLocations', resolved.maxLocations) + assertPositiveInteger('maxResultChars', resolved.maxResultChars) + assertTimer('timeoutMs', resolved.timeoutMs) + + ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, text: LSP_PROMPT_TEXT }) + + ctx.tools.register(defineTool({ + name: 'lsp', + description: + 'Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.', + parameters: { + operation: { + type: 'string', + required: true, + enum: [...LSP_OPERATIONS], + description: 'goToDefinition, findReferences, goToImplementation, or hover.', + }, + file_path: { type: 'string', required: true, description: 'The source file to query, relative to the workspace or absolute.' }, + line: { type: 'number', required: true, description: 'One-based line of the cursor.' }, + character: { type: 'number', required: true, description: 'One-based UTF-16 column of the cursor.' }, + }, + timeoutMs: resolved.timeoutMs, + async execute(args, exec): Promise { + const input = parseLspArgs(args) + const workspaceRoot = sessionCwd(exec) + if (workspaceRoot === undefined) { + throw new LspError('the lsp tool requires a session workspace cwd', 'LSP_WORKSPACE_REQUIRED') + } + const result = await ctx.lsp.query({ + operation: input.operation, + filePath: input.filePath, + position: input.position, + workspaceRoot, + }, exec.signal) + switch (result.kind) { + case 'locations': + // Relativize against the provider's canonical workspace root (which its file: URIs are + // relative to), not the session cwd: a symlinked cwd would otherwise misclassify every + // in-workspace location as external and render it as an absolute path. + return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations, resolved.maxResultChars) }] + case 'hover': + return [{ type: 'text', text: formatHover(result.hover, resolved.maxResultChars) }] + /* v8 ignore next -- exhaustive over the closed LspQueryResult union; unreachable. */ + default: + return assertNever(result, 'tool-lsp result') + } + }, + presentCall: presentLspCall, + })) +} + +/** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`tool-lsp: ${name} must be a positive integer`) + } +} + +/** Reject a timer value Node would clamp instead of scheduling as configured. */ +function assertTimer(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) { + throw new Error(`tool-lsp: ${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`) + } +} diff --git a/packages/lsp/tool-lsp/src/invariant.ts b/packages/lsp/tool-lsp/src/invariant.ts new file mode 100644 index 0000000000..a2516e059b --- /dev/null +++ b/packages/lsp/tool-lsp/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-lsp`. + * @module @deepseek-ai/dsh-tool-lsp/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lsp' + +/** Cordis companion plugin name. */ +export const name = 'tool-lsp-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this stateless adapter contributes one tool and prompt section, while query + * lifecycle and result relations remain owned by the tool and LSP seams it composes. + */ +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/lsp/tool-lsp/src/render.ts b/packages/lsp/tool-lsp/src/render.ts new file mode 100644 index 0000000000..b6341ae407 --- /dev/null +++ b/packages/lsp/tool-lsp/src/render.ts @@ -0,0 +1,168 @@ +/** + * Pure formatting and coordinate conversion for the `lsp` tool: one-based↔zero-based UTF-16 cursor + * conversion, workspace-grouped location rendering with `file:`-URI resolution, complete-result + * capping, and ACP presentation. No I/O — a UI may call the presenter on live streaming and on + * replay, so it depends only on the tool arguments. + * @module @deepseek-ai/dsh-tool-lsp/render + */ + +import { fileURLToPath } from 'node:url' +import { isAbsolute, relative, sep } from 'node:path' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp' + +/** The four operations the tool exposes, as a runtime tuple for schema enum + validation. */ +export const LSP_OPERATIONS: readonly LspOperation[] = ['goToDefinition', 'findReferences', 'goToImplementation', 'hover'] + +/** Default cap on rendered locations before an omission marker is appended. */ +export const DEFAULT_MAX_LOCATIONS = 100 + +/** Default cap on the complete rendered tool result, including truncation metadata. */ +export const DEFAULT_MAX_RESULT_CHARS = 16_000 + +/** Validated `lsp` arguments after coordinate checks. */ +export interface LspToolInput { + readonly operation: LspOperation + readonly filePath: string + /** Zero-based UTF-16 position converted from the one-based model coordinates. */ + readonly position: LspPosition +} + +/** The raw, schema-typed argument shape. */ +export interface LspToolArgs { + readonly operation: string + readonly file_path: string + readonly line: number + readonly character: number +} + +/** + * Validate and convert model arguments: `operation` must be one of the four; `line`/`character` are + * positive one-based integers converted to the seam's zero-based position. + * @param args - the schema-validated raw arguments. + * @returns the validated input with a zero-based position. + * @throws Error when the operation is unknown or a coordinate is not a positive integer. + */ +export function parseLspArgs(args: LspToolArgs): LspToolInput { + if (!isOperation(args.operation)) { + throw new Error(`operation must be one of ${LSP_OPERATIONS.join(', ')}`) + } + if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + const line = oneBased(args.line, 'line') + const character = oneBased(args.character, 'character') + return { + operation: args.operation, + filePath: args.file_path, + // The model counts from 1; the seam (and protocol) count from 0. + position: { line: line - 1, character: character - 1 }, + } +} + +/** Whether a string is one of the four operations. */ +function isOperation(value: string): value is LspOperation { + return (LSP_OPERATIONS as readonly string[]).includes(value) +} + +/** Validate a one-based coordinate is a positive integer. */ +function oneBased(value: number, name: string): number { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer (one-based)`) + } + return value +} + +/** + * Render a locations result grouped by file, converting each zero-based location back to a one-based + * `path:line:character` entry. A `file:` URI inside the workspace becomes a workspace-relative path; + * outside it, an absolute path; a non-`file:` URI is kept verbatim. Applies `maxLocations` and + * appends an omission marker when it truncates by count, then applies the complete result cap. + * @param locations - the seam's locations (possibly empty). + * @param workspaceRoot - the canonical workspace root for relativizing `file:` paths. + * @param maxLocations - the cap before truncation. + * @param maxResultChars - the complete rendered-text cap, including truncation metadata. + * @returns the rendered text; a distinct no-result line when there are none. + */ +export function formatLocations( + locations: readonly LspLocation[], + workspaceRoot: string, + maxLocations: number, + maxResultChars: number, +): string { + if (locations.length === 0) return boundResult('No results.', maxResultChars, 'locations') + const shown = locations.slice(0, maxLocations) + const omitted = locations.length - shown.length + const grouped = new Map() + for (const location of shown) { + const path = renderUri(location.uri, workspaceRoot) + const line = location.range.start.line + 1 + const character = location.range.start.character + 1 + const entries = grouped.get(path) ?? [] + entries.push(`${path}:${line}:${character}`) + grouped.set(path, entries) + } + const lines: string[] = [] + for (const entries of grouped.values()) lines.push(...entries) + if (omitted > 0) { + lines.push(`… ${omitted} more location${omitted === 1 ? '' : 's'} omitted (limit ${maxLocations}).`) + } + return boundResult(lines.join('\n'), maxResultChars, 'locations') +} + +/** + * Render a hover result, applying `maxResultChars` last and keeping its marker within the cap. + * @param hover - the normalized hover, or `null` for no hover. + * @param maxResultChars - the complete rendered-text cap, including truncation metadata. + * @returns the rendered hover text; a distinct no-result line for `null`. + */ +export function formatHover(hover: LspHover | null, maxResultChars: number): string { + const text = hover === null ? 'No hover information.' : hover.contents + return boundResult(text, maxResultChars, 'hover') +} + +/** Bound a complete rendered result, including the truncation notice itself. */ +function boundResult(text: string, maxChars: number, label: string): string { + if (text.length <= maxChars) return text + const notice = `\n… ${label} truncated (limit ${maxChars} characters).` + if (notice.length >= maxChars) return notice.slice(0, maxChars) + return `${text.slice(0, maxChars - notice.length)}${notice}` +} + +/** + * Resolve a location URI to a display path. A `file:` URI accepted by Node becomes workspace-relative + * (inside) or absolute (outside); any other URI is returned verbatim. + * @param uri - the target URI from the seam. + * @param workspaceRoot - the canonical workspace root. + * @returns the display path or the verbatim URI. + */ +export function renderUri(uri: string, workspaceRoot: string): string { + if (!uri.startsWith('file:')) return uri + let absolute: string + try { + absolute = fileURLToPath(uri) + } catch { + // A malformed file: URI is not a path we can resolve; show it verbatim. + return uri + } + const rel = relative(workspaceRoot, absolute) + if (rel === '') return '.' + // A leading `..` SEGMENT (or an absolute rel) means outside the workspace; guard against a false + // positive on an in-workspace path whose first component merely starts with dots (e.g. `..gen/x`). + const outside = rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) + return outside ? absolute : rel.split(sep).join('/') +} + +/** + * ACP presentation for a pending `lsp` call. Uses a generic search card; the title carries the + * operation and one-based cursor, and `locations` focuses the queried line (ACP `FileLocation` has + * no character, so the title preserves the column). + * @param args - the raw tool arguments. + * @returns the generic call view. + */ +export function presentLspCall(args: LspToolArgs): GenericCallView { + return { + card: 'generic', + kind: 'search', + title: `LSP ${args.operation} ${args.file_path}:${args.line}:${args.character}`, + locations: [{ path: args.file_path, line: args.line }], + } +} diff --git a/packages/lsp/tool-lsp/src/session-cwd.ts b/packages/lsp/tool-lsp/src/session-cwd.ts new file mode 100644 index 0000000000..7fc41785de --- /dev/null +++ b/packages/lsp/tool-lsp/src/session-cwd.ts @@ -0,0 +1,19 @@ +/** + * Derive the workspace root an `lsp` call resolves against: the calling agent's per-session + * workspace (`exec.agent.session.header.cwd`), mirroring how the filesystem tools resolve paths. + * Unlike those tools, LSP has NO provider fallback — a missing cwd fails the call as + * `LSP_WORKSPACE_REQUIRED`, because the local provider must canonicalize a real workspace before it + * can start a server. + * @module @deepseek-ai/dsh-tool-lsp/session-cwd + */ + +import type { ToolExecution } from '@deepseek-ai/dsh-tools' + +/** + * The session workspace cwd for this call, or `undefined` when none applies. + * @param exec - the tool-execution context; only its optional `agent` is read. + * @returns the calling agent's session cwd, or undefined for a non-agent caller. + */ +export function sessionCwd(exec: ToolExecution): string | undefined { + return exec.agent?.session.header.cwd +} diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts new file mode 100644 index 0000000000..8b4cb79de6 --- /dev/null +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import Lsp from '@deepseek-ai/dsh-lsp' +import * as LspLocal from '@deepseek-ai/dsh-lsp-local' +import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy' +import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' + +/** + * Focused in-process integration of the model-facing tool, seam, local provider, and timeout policy. + * The `lsp-definition` ACP snapshot owns the shipped Loader/app entry path. + */ + +let root: string +let ws: string + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-tool-int-'))) + ws = join(root, 'ws') + await mkdir(ws) + await writeFile(join(ws, 'a.ts'), 'const x = 1\n') +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +/** An inline stdio server that answers initialize + definition; `hang` makes textDocument/* stall. */ +function serverScript(hang: boolean): string { + const definition = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }) + return 'let b=Buffer.alloc(0);' + + `const DEF=${definition};` + + 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + servers: { + inline: { + command: process.execPath, + args: ['-e', serverScript(hang)], + extensionToLanguage: { '.ts': 'typescript' }, + shutdownTimeoutMs: 200, + killGraceMs: 200, + }, + }, + }) + await ctx.plugin(TimeoutPolicy) + await ctx.plugin(ToolLsp, timeoutMs !== undefined ? { timeoutMs } : {}) + return ctx +} + +let seq = 0 +function call(ctx: Context, args: unknown) { + return ctx.tools.execute({ + callId: `int-${++seq}` as never, + name: 'lsp', + arguments: args, + agent: { session: { header: { cwd: ws } } } as never, + }) +} + +describe('tool-lsp integration', () => { + it('round-trips a definition query through the real provider and renders a location', async () => { + const ctx = await mount(false) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 }) + expect(result.isError).toBe(false) + expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) + await ctx.fiber.dispose() + }, 30_000) + + it('enforces the TOOL_TIMEOUT budget when the server hangs', async () => { + const ctx = await mount(true, 300) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 }) + expect(result.isError).toBe(true) + expect(result.error?.code).toBe('TOOL_TIMEOUT') + await ctx.fiber.dispose() + }, 30_000) +}) diff --git a/packages/lsp/tool-lsp/tests/load-path.spec.ts b/packages/lsp/tool-lsp/tests/load-path.spec.ts new file mode 100644 index 0000000000..7b3ec0f241 --- /dev/null +++ b/packages/lsp/tool-lsp/tests/load-path.spec.ts @@ -0,0 +1,24 @@ +/** + * Loader export-shape guard for @deepseek-ai/dsh-tool-lsp. It is a NAMESPACE plugin with `inject`, so a + * stray `export default apply` would make the Loader's `unwrapExports` collapse the module to the + * bare `apply`, dropping `inject` (postmortem 0001). This verifies the namespace survives + * `Loader.prototype.unwrapExports`; the `lsp-definition` ACP snapshot owns full app composition. + */ + +import { describe, expect, it } from 'vitest' +import Loader from '@cordisjs/plugin-loader' +import * as toolLsp from '@deepseek-ai/dsh-tool-lsp' + +describe('dsh-tool-lsp Loader export-shape guard', () => { + it('has no default export and keeps name/inject/Config through unwrapExports', () => { + expect('default' in toolLsp).toBe(false) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(toolLsp) as Record + expect(unwrapped).toBe(toolLsp) + expect(unwrapped.name).toBe('tool-lsp') + expect(unwrapped.inject).toEqual(['tools', 'lsp', 'systemPrompt']) + expect(typeof unwrapped.apply).toBe('function') + expect(unwrapped.Config).toBeDefined() + }) +}) diff --git a/packages/lsp/tool-lsp/tests/render.spec.ts b/packages/lsp/tool-lsp/tests/render.spec.ts new file mode 100644 index 0000000000..a277539879 --- /dev/null +++ b/packages/lsp/tool-lsp/tests/render.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { pathToFileURL } from 'node:url' +import { join } from 'node:path' +import { + DEFAULT_MAX_LOCATIONS, + DEFAULT_MAX_RESULT_CHARS, + formatHover, + formatLocations, + LSP_OPERATIONS, + parseLspArgs, + presentLspCall, + renderUri, +} from '@deepseek-ai/dsh-tool-lsp' +import type { LspLocation } from '@deepseek-ai/dsh-lsp' + +const WS = '/home/u/proj' + +function loc(uri: string, line: number, character = 0): LspLocation { + return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } } +} + +describe('parseLspArgs', () => { + it('accepts the four operations and converts one-based to zero-based', () => { + for (const operation of LSP_OPERATIONS) { + const input = parseLspArgs({ operation, file_path: 'a.ts', line: 3, character: 5 }) + expect(input.operation).toBe(operation) + expect(input.position).toEqual({ line: 2, character: 4 }) + } + }) + + it('rejects an unknown operation', () => { + expect(() => parseLspArgs({ operation: 'rename', file_path: 'a.ts', line: 1, character: 1 })) + .toThrow(/operation must be one of/) + }) + + it('rejects a blank file_path', () => { + expect(() => parseLspArgs({ operation: 'hover', file_path: ' ', line: 1, character: 1 })) + .toThrow(/file_path/) + }) + + it('rejects non-positive or non-integer coordinates', () => { + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(/line/) + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(/character/) + expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(/line/) + }) +}) + +describe('renderUri', () => { + it('relativizes a file: URI inside the workspace with forward slashes', () => { + const uri = pathToFileURL(join(WS, 'src', 'a.ts')).href + expect(renderUri(uri, WS)).toBe('src/a.ts') + }) + + it('returns an absolute path for a file: URI outside the workspace', () => { + const uri = pathToFileURL('/other/lib/b.ts').href + expect(renderUri(uri, WS)).toBe('/other/lib/b.ts') + }) + + it('renders the workspace root itself as "."', () => { + expect(renderUri(pathToFileURL(WS).href, WS)).toBe('.') + }) + + it('keeps an in-workspace path whose first segment starts with dots relative', () => { + // `..generated` is a real in-workspace dir, not a parent escape; only a `..` segment is external. + const uri = pathToFileURL(join(WS, '..generated', 'a.ts')).href + expect(renderUri(uri, WS)).toBe('..generated/a.ts') + }) + + it('keeps a non-file URI verbatim', () => { + expect(renderUri('untitled:Untitled-1', WS)).toBe('untitled:Untitled-1') + expect(renderUri('jdt://contents/Foo.class', WS)).toBe('jdt://contents/Foo.class') + }) + + it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => { + // A file: URI with a host that fileURLToPath rejects falls through to the verbatim path. + expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal') + }) +}) + +describe('formatLocations', () => { + it('renders a no-result line for an empty list', () => { + expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)).toBe('No results.') + }) + + it('renders one-based path:line:character grouped by file', () => { + const a = pathToFileURL(join(WS, 'a.ts')).href + const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS) + expect(text).toBe('a.ts:1:1\na.ts:5:3') + }) + + it('caps at maxLocations and marks the omission', () => { + const a = pathToFileURL(join(WS, 'a.ts')).href + const many = Array.from({ length: 5 }, (_, i) => loc(a, i)) + const text = formatLocations(many, WS, 2, DEFAULT_MAX_RESULT_CHARS) + expect(text).toContain('a.ts:1:1') + expect(text).toContain('3 more locations omitted (limit 2).') + }) + + it('uses the singular omission marker for exactly one extra', () => { + const a = pathToFileURL(join(WS, 'a.ts')).href + const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1, DEFAULT_MAX_RESULT_CHARS) + expect(text).toContain('1 more location omitted (limit 1).') + }) + + it('caps the complete location text even when one URI is enormous', () => { + const maxResultChars = 80 + const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS, 1, maxResultChars) + expect(text).toHaveLength(maxResultChars) + expect(text).toContain('locations truncated') + }) +}) + +describe('formatHover', () => { + it('renders a no-result line for null', () => { + expect(formatHover(null, DEFAULT_MAX_RESULT_CHARS)).toBe('No hover information.') + }) + + it('returns short hover verbatim', () => { + expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_RESULT_CHARS)).toBe('```ts\nx: number\n```') + }) + + it('caps the complete hover text including its truncation marker', () => { + const text = formatHover({ contents: 'a'.repeat(100) }, 60) + expect(text).toHaveLength(60) + expect(text).toContain('hover truncated (limit 60 characters).') + }) + + it('still honors a cap smaller than the truncation marker', () => { + expect(formatHover({ contents: 'a'.repeat(100) }, 10)).toHaveLength(10) + }) +}) + +describe('presentLspCall', () => { + it('is a generic search card with an operation/cursor title and a line location', () => { + expect(presentLspCall({ operation: 'findReferences', file_path: 'a.ts', line: 3, character: 7 })).toEqual({ + card: 'generic', + kind: 'search', + title: 'LSP findReferences a.ts:3:7', + locations: [{ path: 'a.ts', line: 3 }], + }) + }) +}) diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts new file mode 100644 index 0000000000..37c995a3a6 --- /dev/null +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp' +import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' +import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' + +/** A scripted provider recording queries; `respond` yields the result or throws. */ +function stubProvider( + respond: (request: LspProviderQuery) => LspQueryResult, + extensionToLanguage: Record = { '.ts': 'typescript' }, +): LspProvider & { seen: LspProviderQuery[] } { + const seen: LspProviderQuery[] = [] + return { + id: LspProviderId('stub'), + extensionToLanguage, + seen, + query(request) { + seen.push(request) + return Promise.resolve(respond(request)) + }, + } +} + +/** Mount the real tool stack over a real seam plus one stub provider. */ +async function mount( + provider?: LspProvider, + config: ToolLsp.Config = {}, +): Promise<{ ctx: Context }> { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(Lsp) + if (provider) (ctx.lsp as Lsp).registerProvider(provider) + await ctx.plugin(ToolLsp, config) + return { ctx } +} + +let seq = 0 +/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */ +function call(ctx: Context, args: unknown, cwd: string | null = '/ws') { + return ctx.tools.execute({ + callId: `c-${++seq}` as never, + name: 'lsp', + arguments: args, + ...cwd !== null ? { agent: { session: { header: { cwd } } } as never } : {}, + }) +} + +const okLocations: LspQueryResult = { + kind: 'locations', + locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }], + resolvedWorkspaceRoot: '/ws', +} + +describe('tool-lsp registration', () => { + it('registers the lsp tool and its prompt section', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + expect(ctx.tools.get('lsp')).toBeDefined() + const prompt = await ctx.systemPrompt.assemble() + const text = prompt.sections.map(s => s.text).join('\n') + expect(text).toContain(LSP_PROMPT_TEXT) + }) + + it('attaches the default timeout budget to the tool definition', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + expect(ctx.tools.get('lsp')?.timeoutMs).toBe(DEFAULT_LSP_TOOL_TIMEOUT_MS) + }) + + it('honors a configured timeout override', async () => { + const { ctx } = await mount(stubProvider(() => okLocations), { timeoutMs: 5000 }) + expect(ctx.tools.get('lsp')?.timeoutMs).toBe(5000) + }) + + it('exposes exactly the four operations in the schema enum', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + const schema = ctx.tools.get('lsp')?.parameters as { properties: { operation: { enum: string[] } } } + expect(schema.properties.operation.enum).toEqual(['goToDefinition', 'findReferences', 'goToImplementation', 'hover']) + }) + + it('has no default export (namespace plugin shape)', () => { + expect((ToolLsp as { default?: unknown }).default).toBeUndefined() + }) + + it('rejects a non-positive config value at load', async () => { + await expect(mount(stubProvider(() => okLocations), { maxLocations: 0 })).rejects.toThrow(/maxLocations/) + }) + + it('rejects a timeout above Node timer range at load', async () => { + await expect(mount(stubProvider(() => okLocations), { timeoutMs: MAX_TIMER_DELAY_MS + 1 })) + .rejects.toThrow(/timeoutMs/) + expect(() => { + ToolLsp.apply(new Context(), { + maxLocations: 100, + maxResultChars: 16_000, + timeoutMs: MAX_TIMER_DELAY_MS + 1, + }) + }).toThrow(/timeoutMs/) + }) +}) + +describe('tool-lsp execution', () => { + it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => { + const provider = stubProvider(() => okLocations) + const { ctx } = await mount(provider) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws') + expect(result.isError).toBe(false) + expect(provider.seen[0]).toMatchObject({ + operation: 'goToDefinition', + filePath: 'a.ts', + position: { line: 2, character: 4 }, + workspaceRoot: '/ws', + }) + }) + + it('renders locations relative to the workspace', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) + }) + + it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => { + // A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's + // location URIs are under. Relativizing against the alias would misclassify the location as + // external and print an absolute path; the tool must use resolvedWorkspaceRoot. + const provider = stubProvider(() => ({ + kind: 'locations', + locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }], + resolvedWorkspaceRoot: '/real/ws', + })) + const { ctx } = await mount(provider) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias') + expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' }) + expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) + }) + + it('renders hover content', async () => { + const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } }))) + const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + expect(result.content[0]).toEqual({ type: 'text', text: 'number' }) + }) + + it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, null) + expect(result.isError).toBe(true) + expect(result.error?.code).toBe('LSP_WORKSPACE_REQUIRED') + }) + + it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => { + const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' })) + const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + expect(result.isError).toBe(true) + expect(result.error?.code).toBe('LSP_UNAVAILABLE') + }) + + it('returns a structured INVALID_ARGS on a bad operation', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + expect(result.isError).toBe(true) + expect(result.error?.code).toBe('INVALID_ARGS') + }) + + it('forwards exec.signal to the seam query', async () => { + const seen: (AbortSignal | undefined)[] = [] + const provider: LspProvider = { + id: LspProviderId('sig'), + extensionToLanguage: { '.ts': 'typescript' }, + query(_request, signal) { + seen.push(signal) + return Promise.resolve(okLocations) + }, + } + const { ctx } = await mount(provider) + await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws') + // The timeout policy is not mounted here, so the signal is whatever the registry passes (may be + // undefined); the point is the tool threads it through without throwing. + expect(seen).toHaveLength(1) + }) + + it('presentCall renders the pending card from args', async () => { + const { ctx } = await mount(stubProvider(() => okLocations)) + const view = ctx.tools.get('lsp')?.presentCall?.({ operation: 'hover', file_path: 'a.ts', line: 2, character: 3 }) + expect(view).toEqual({ + card: 'generic', + kind: 'search', + title: 'LSP hover a.ts:2:3', + locations: [{ path: 'a.ts', line: 2 }], + }) + }) +}) diff --git a/packages/lsp/tool-lsp/tsconfig.json b/packages/lsp/tool-lsp/tsconfig.json new file mode 100644 index 0000000000..f99783e94e --- /dev/null +++ b/packages/lsp/tool-lsp/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../util/timeout" + }, + { + "path": "../lsp" + }, + { + "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..e2d8ac22cb --- /dev/null +++ b/packages/mcp/mcp-client/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..e990d46acc --- /dev/null +++ b/packages/sandbox/sandbox-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/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. */ 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-policy/README.md b/packages/sandbox/sandbox-policy/README.md index fac9f5730b..783b338acf 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -19,6 +19,8 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de - `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band. - `SANDBOX_MODES` — every mode, for option advertisement and runtime validation. +The optional `./invariant` companion rejects a forged durable `sandbox/mode` event whose value falls outside that closed vocabulary; Session and its companion own the surrounding storage and turn-enclosure rules. + ## The per-session store A runtime switch (an ACP `session/set_config_option`, a test scenario) is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 39c01b450a..d5f9270ed1 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-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-sandbox": "^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-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts new file mode 100644 index 0000000000..90b8bf65fd --- /dev/null +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -0,0 +1,42 @@ +/** Package-owned session-event invariants for sandbox policy. @module @deepseek-ai/dsh-sandbox-policy/invariant */ + +import type { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { SANDBOX_MODES } from './session-mode.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-policy' + +/** Cordis companion plugin name. */ +export const name = 'sandbox-policy-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/* 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] + validateEvent(event, fail) + }, { global: true }) +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ + +/** + * 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)) diff --git a/packages/sandbox/sandbox-policy/tests/invariant.spec.ts b/packages/sandbox/sandbox-policy/tests/invariant.spec.ts new file mode 100644 index 0000000000..d3255b305e --- /dev/null +++ b/packages/sandbox/sandbox-policy/tests/invariant.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +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(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(SandboxPolicyInvariant) + return ctx +} + +function modeEvent(mode: string): SessionEvent { + return { type: 'sandbox/mode', seq: 0, time: 0, data: { mode } } as SessionEvent +} + +describe('sandbox-policy invariants', () => { + it.each(['read-only', 'workspace-write', 'danger-full-access'])( + 'accepts the durable %s mode', + async (mode) => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', {} as Session, modeEvent(mode)) }).not.toThrow() + }, + ) + + it('ignores unrelated event streams', async () => { + const ctx = await setup() + 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 and attributes an unknown durable sandbox mode', async () => { + const ctx = await setup() + 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/tsconfig.json b/packages/sandbox/sandbox-policy/tsconfig.json index fc0c96c6de..cb6fc623d0 100644 --- a/packages/sandbox/sandbox-policy/tsconfig.json +++ b/packages/sandbox/sandbox-policy/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../support/invariants" } ] } 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/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..7ee5be733f --- /dev/null +++ b/packages/sandbox/sandbox/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox' + +/** Cordis companion plugin name. */ +export const name = 'sandbox-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..368b46fc70 --- /dev/null +++ b/packages/sdk/create-sdk/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/create-sdk' + +/** Cordis companion plugin name. */ +export const name = 'create-sdk-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/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/src/invariant.ts b/packages/sdk/helper/src/invariant.ts new file mode 100644 index 0000000000..9185ac8867 --- /dev/null +++ b/packages/sdk/helper/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-helper' + +/** Cordis companion plugin name. */ +export const name = 'helper-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 4df8c5d3f2..bc3e5ebe12 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('tui')?.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') @@ -888,6 +894,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/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 6fdbcbc3da..63afe96d08 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", @@ -38,16 +43,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..72e97f3628 --- /dev/null +++ b/packages/sdk/scripts/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-scripts' + +/** Cordis companion plugin name. */ +export const name = 'scripts-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/tests/scripts.spec.ts b/packages/sdk/scripts/tests/scripts.spec.ts index a6ec4f4a4f..fa8eb00d41 100644 --- a/packages/sdk/scripts/tests/scripts.spec.ts +++ b/packages/sdk/scripts/tests/scripts.spec.ts @@ -135,6 +135,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/sdk/scripts/tsconfig.json b/packages/sdk/scripts/tsconfig.json index 461c86c06d..d3dacea10b 100644 --- a/packages/sdk/scripts/tsconfig.json +++ b/packages/sdk/scripts/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../helper" }, { "path": "../telemetry" }, { "path": "../../ui/app-boot" }, - { "path": "../../../vendor/cordis" } + { "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/sdk/telemetry/package.json b/packages/sdk/telemetry/package.json index fcb6efb797..7dd1ffd0de 100644 --- a/packages/sdk/telemetry/package.json +++ b/packages/sdk/telemetry/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,10 +31,12 @@ }, "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/sdk/telemetry/src/invariant.ts b/packages/sdk/telemetry/src/invariant.ts new file mode 100644 index 0000000000..c3676a1384 --- /dev/null +++ b/packages/sdk/telemetry/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-telemetry`. + * @module @deepseek-ai/dsh-telemetry/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-telemetry' + +/** Cordis companion plugin name. */ +export const name = 'telemetry-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/telemetry/tsconfig.json b/packages/sdk/telemetry/tsconfig.json index 8acc8f11c5..4667bad772 100644 --- a/packages/sdk/telemetry/tsconfig.json +++ b/packages/sdk/telemetry/tsconfig.json @@ -8,6 +8,7 @@ "src" ], "references": [ - { "path": "../../util/brand" } + { "path": "../../util/brand" }, + { "path": "../../support/invariants" } ] } 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..94d7c2b494 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 90d6fe9e36..8a06fa6409 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 { @@ -257,7 +255,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) @@ -436,12 +434,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')) @@ -635,7 +635,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, @@ -703,7 +703,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 @@ -814,18 +814,19 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) 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..9d841a053d --- /dev/null +++ b/packages/session-persistence/session-persistence-sqlite/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 83d16b3315..e054f9fa9c 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..316774f3fd --- /dev/null +++ b/packages/session-persistence/session-persistence/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 5ef2ce2fad..620d069d32 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) @@ -132,17 +133,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() @@ -187,7 +188,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 { @@ -203,7 +204,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() } @@ -215,7 +216,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]) @@ -233,13 +234,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 { @@ -254,6 +256,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. @@ -282,7 +285,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 @@ -292,7 +295,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) @@ -312,7 +315,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 @@ -324,7 +327,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) @@ -343,7 +346,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() @@ -353,7 +356,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']) @@ -373,7 +376,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() } @@ -413,7 +416,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 { @@ -459,14 +462,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() @@ -711,11 +715,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..d087dd2378 --- /dev/null +++ b/packages/session-query/session-query/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..6d4917a4d9 --- /dev/null +++ b/packages/skill/skill-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..5145dee6da --- /dev/null +++ b/packages/skill/skill/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-skill' + +/** Cordis companion plugin name. */ +export const name = 'skill-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..68d70fa2d2 --- /dev/null +++ b/packages/skill/tool-skill/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..4b44ddbebf --- /dev/null +++ b/packages/spill/spill-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..82a4bee211 --- /dev/null +++ b/packages/spill/spill-policy/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 2449f26a8c..d249430ab1 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -109,6 +109,7 @@ describe('config validation', () => { it('rejects a fractional maxInlineBytes at load', async () => { await expect(setup({ maxInlineBytes: 1.5 })).rejects.toThrow(/non-negative integer/) }) + }) describe('oversized plain-text replacement', () => { 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..5011ac1d52 --- /dev/null +++ b/packages/spill/spill/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-spill' + +/** Cordis companion plugin name. */ +export const name = 'spill-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/README.md b/packages/subagent/subagent-acp/README.md index 7ac583575b..fd3f0fb5a4 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -4,7 +4,9 @@ The ACP provider runs each subagent in a fresh subprocess and drives it as an Ag ## Start and ownership -`start(request)` performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped. +`start(request)` resolves the child's working directory, then performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped; a working-directory resolution failure rejects before anything is spawned. + +The working directory is the configured `cwd` override when set, else the delegating parent session's cwd — never the server process's own cwd, because one server process serves sessions from many workspaces. The parent-derived value must be an absolute path naming a directory the harness can enter (search permission — what a subprocess cwd needs), and the same resolved path becomes both the subprocess cwd and the ACP `session/new` workspace. The returned run id is minted in the parent namespace. The child server's session id remains private to ACP wire calls because ACP guarantees it only within that fresh child process; using it as the parent lifecycle id could collide with another remote run or a local agent. @@ -14,7 +16,7 @@ After publication, the provider sends the prompt and collects streamed `agent_me ## Capabilities and context -ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh and ignores `request.parent` beyond the seam's required attribution field. +ACP advertises no start-time capabilities because this process cannot enforce the remote child's depth, tool filter, persona, or structured-output runtime. It also reports `inheritsParentContext: false`: the remote session starts fresh, and the only parent-derived input is the workspace cwd described above — no conversation context crosses the process boundary. ## Configuration @@ -23,7 +25,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th | `providerName` | `acp` | Registry name on `ctx.subagents`. | | `command` | required | Executable spawned for each run. | | `args` | `[]` | Command arguments. | -| `cwd` | process cwd | Child process and ACP session working directory. | +| `cwd` | parent session cwd | Working-directory override for the child process and its ACP session; must be non-empty, a relative value resolves against the harness launch directory at load, and the result must name a directory the harness can enter. | | `permission` | `reject` | Auto-answer permission requests by rejecting or choosing the first allow-shaped option. | | `env` | `{}` | Explicit child environment layered over a credential-scrubbed parent environment. | | `disposeEofGraceMs` | `6000` | Grace after stdin EOF before SIGTERM. | @@ -57,7 +59,7 @@ The child environment is built by [`buildChildEnv`](../subagent-subprocess/READM The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). -Keyless tests drive a scripted ACP subprocess over real stdio. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`. +Keyless tests drive a scripted ACP subprocess over real stdio, including a Loader-composed stdio app proving parent-session cwd inheritance end to end. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`. ## Model Experience @@ -92,6 +94,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **A fresh process per run** — persistent-process pooling is a future optimization ([the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)). +- **Local workspaces only** — the resolved cwd is a local path handed to a child on the same machine; workspace mapping for a remote ACP agent would need its own backend capability and is not designed here. - **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them. - **Only `agent_message_chunk` text is collected** — the child's tool-call activity, thought chunks, and plan updates are not surfaced to the parent. - **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission` in this cut. 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/index.ts b/packages/subagent/subagent-acp/src/index.ts index 80766ed831..4aaf9bc071 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -1,11 +1,14 @@ /** * Out-of-process ACP subagent backend. Each child has its own process, session, model, and - * tools, so it shares no Cordis context, ignores `request.parent`, and advertises no parent- - * enforced start capabilities. This plugin uses named exports only; a default would hide its + * tools, so it shares no Cordis context and advertises no parent-enforced start capabilities; + * the ONE thing it reads off `request.parent` is the session's workspace cwd (see + * {@link resolveCwd}). This plugin uses named exports only; a default would hide its * loader metadata (see `docs/postmortem/0001-acp-default-export-drops-inject.md`). * @module @deepseek-ai/dsh-subagent-acp */ +import { accessSync, constants, statSync } from 'node:fs' +import { isAbsolute, resolve } from 'node:path' import type { Context } from 'cordis' import z from 'schemastery' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -23,8 +26,11 @@ export interface Config { /** Arguments passed to {@link command}. */ args: string[] /** - * Working directory for the child process and its ACP session. Defaults to - * the parent process's cwd when omitted. + * Working directory override for the child process and its ACP session. + * Must be non-empty; a relative path resolves against the harness launch + * directory at load, and the result must be an existing directory. When + * omitted, each child inherits its delegating parent session's cwd — and + * starting one from a parent session that has no cwd fails. */ cwd?: string /** @@ -71,6 +77,60 @@ function assertPositiveFinite(name: string, value: number): void { /** The shape after schemastery applied the defaults (cwd has none). */ type ResolvedConfig = Required> & Pick +/** + * Whether `path` names an existing directory the harness can ENTER. The + * search-permission probe matters: `statSync().isDirectory()` is true for a + * mode-600 directory, but a subprocess cwd needs `X_OK` or spawn fails EACCES. + */ +function isDirectory(path: string): boolean { + try { + if (!statSync(path).isDirectory()) return false + accessSync(path, constants.X_OK) + return true + } catch { + // statSync/accessSync throw only filesystem access errors here + // (ENOENT/EACCES/ENOTDIR/…), and every one of them means the path cannot + // serve as the child's cwd. + return false + } +} + +/** + * Assert `cwd` can actually host the child: absolute (it doubles as the ACP + * session workspace, and a relative path would be re-anchored to the server + * process's launch directory) and an existing directory (fail here, before the + * process boundary, instead of as an ambiguous spawn ENOENT). + * @param label - which source supplied the value, for the diagnostic. + * @param cwd - the candidate working directory. + * @returns `cwd`, validated. + */ +function assertUsableCwd(label: string, cwd: string): string { + if (!isAbsolute(cwd)) { + throw new Error(`subagent-acp: ${label} must be an absolute path: ${cwd}`) + } + if (!isDirectory(cwd)) { + throw new Error(`subagent-acp: ${label} is not an accessible directory: ${cwd}`) + } + return cwd +} + +/** + * Resolve the child's working directory: the deployment `cwd` override when + * configured (already validated at load), else the parent session's workspace + * cwd (validated here, its earliest resolvable point). Fails loud when neither + * exists — falling back to the harness process cwd would silently bind the + * child to the server's launch directory instead of the delegating session's + * workspace (one server process serves many sessions, each with its own cwd). + */ +function resolveCwd(configured: string | undefined, request: SubagentStartRequest): string { + if (configured !== undefined) return configured + const parentCwd = request.parent.session.header.cwd + if (parentCwd === undefined) { + throw new Error('subagent-acp: no working directory for the child — configure `cwd` or delegate from a parent session that has one') + } + return assertUsableCwd('parent session cwd', parentCwd) +} + /** * The ACP provider. Advertises NO start-time capabilities: an out-of-process * child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects @@ -87,7 +147,7 @@ class AcpProvider implements SubagentProvider { const spec: AcpRunSpec = { command: this.config.command, args: this.config.args, - cwd: this.config.cwd ?? process.cwd(), + cwd: resolveCwd(this.config.cwd, request), permission: this.config.permission, env: this.config.env, disposeEofGraceMs: this.config.disposeEofGraceMs, @@ -107,5 +167,15 @@ export function apply(ctx: Context, config: Config): void { const resolved = config as ResolvedConfig assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs) assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs) - ctx.subagents.registerProvider(new AcpProvider(resolved.providerName, ctx, resolved)) + // `path.resolve('')` is the process cwd — an empty string would silently + // reintroduce the launch-directory fallback this resolution removed. + if (resolved.cwd === '') { + throw new Error('subagent-acp: config cwd must not be empty — omit the key to inherit the parent session cwd') + } + // Interpret a relative configured cwd against the harness launch directory + // ONCE, at load, and fail a misconfigured directory here — not per start. + const validated: ResolvedConfig = resolved.cwd === undefined + ? resolved + : { ...resolved, cwd: assertUsableCwd('config cwd', resolve(resolved.cwd)) } + ctx.subagents.registerProvider(new AcpProvider(validated.providerName, ctx, validated)) } diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts new file mode 100644 index 0000000000..85c1601348 --- /dev/null +++ b/packages/subagent/subagent-acp/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index a10a87a940..82e7f1e08e 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -37,7 +37,11 @@ export interface AcpRunSpec { command: string /** Arguments passed to {@link command}. */ args: string[] - /** Working directory for the child process AND its ACP session `cwd`. */ + /** + * Absolute working directory for the child process AND its ACP session + * `cwd`. The provider resolves it before this spec exists: config override, + * else the delegating parent session's workspace. + */ cwd: string /** How to auto-answer the child's permission prompts. */ permission: PermissionPolicy diff --git a/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts b/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..900a28f4ec --- /dev/null +++ b/packages/subagent/subagent-acp/tests/loader-composition.e2e.ts @@ -0,0 +1,73 @@ +import { realpathSync } from 'node:fs' +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +/** + * Keyless REAL-composition coverage for parent-session cwd inheritance: a + * test-only cordis.yml boots the headless app through the Loader with the ACP + * backend's `cwd` omitted, a scripted model delegates once, and the scripted + * mock ACP child echoes where it actually ran plus the workspace it was + * announced — both must be the parent session's cwd. Mock-only composition, so + * only this keyless tier applies (the with-key tier lives in subagent-acp.e2e.ts). + */ + +const driver = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml', + import.meta.url, +)) +const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +describe('ACP subagent cwd inheritance through a real cordis.yml', () => { + it('runs the child in the parent session workspace and announces it as the ACP session cwd', async () => { + let events: SessionEvent[] = [] + let workspace = '' + const { stderr } = await runLoaderSmoke({ + label: 'acp-subagent cwd composition smoke', + tempDirPrefix: 'acp-subagent-cwd-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + env: { DSH_TEST_MOCK_ACP_SERVER: mockServer }, + inspect: async (cwd) => { + // The child reports realpaths; canonicalize the temp workspace to match. + workspace = realpathSync(cwd) + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + }, + }) + expect(stderr).not.toContain('UNHANDLED') + + // The tool result carries the child's two-line echo: its real process.cwd() + // and the cwd the backend announced in `session/new` — both the parent + // session's workspace, never the harness process's launch directory. + const results = events.filter(event => event.type === 'tool/result') + expect(results).toHaveLength(1) + const resultText = results[0]!.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + expect(resultText).toBe(`${workspace}\n${workspace}`) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 2bbf457c18..6b3f8157e8 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -15,6 +15,11 @@ * `dispose()` must still kill the process. * - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission` * before answering, to exercise the client's auto-answer. + * - `MOCK_ECHO_CWD` — if `1`, ignore MOCK_TEXT and stream two lines instead: + * the agent PROCESS's `process.cwd()` and the `cwd` the + * client announced in `session/new` — so a test can assert + * where the child actually ran and what workspace it was + * told it has. * - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt` * handler is in flight (it has streamed its chunk). A test * polls for this file to cancel on a CONDITION rather than @@ -63,6 +68,7 @@ import { } from '@agentclientprotocol/sdk' const TEXT = process.env.MOCK_TEXT ?? 'mock child answer' +const ECHO_CWD = process.env.MOCK_ECHO_CWD === '1' const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason const HANG = process.env.MOCK_HANG === '1' const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1' @@ -83,6 +89,8 @@ function makeAgent(conn: AgentSideConnection): Agent { // Pending cancel resolver for the HANG path: a `session/cancel` resolves the // prompt with `cancelled`. let resolveCancel: ((reason: StopReason) => void) | undefined + // The cwd the client announced in `session/new`, echoed under MOCK_ECHO_CWD. + let sessionCwd: string | undefined return { initialize(_params: InitializeRequest): Promise { @@ -92,7 +100,8 @@ function makeAgent(conn: AgentSideConnection): Agent { authMethods: [], }) }, - async newSession(_params: NewSessionRequest): Promise { + async newSession(params: NewSessionRequest): Promise { + sessionCwd = params.cwd // Optionally signal "newSession reached" and block until released, so a // test can cancel DURING newSession (the early-cancel race window) on a // condition rather than a timeout. @@ -136,10 +145,14 @@ function makeAgent(conn: AgentSideConnection): Agent { update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking…' } }, }) } - // Stream the canned assistant text as one chunk. + // Stream the canned assistant text as one chunk (or, under MOCK_ECHO_CWD, + // the observable process cwd + announced session cwd). await conn.sessionUpdate({ sessionId: params.sessionId, - update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: TEXT } }, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: ECHO_CWD ? `${process.cwd()}\n${sessionCwd ?? ''}` : TEXT }, + }, }) // Signal "prompt is in flight" by touching the readiness file, so a test // can wait on a CONDITION (file exists) rather than an arbitrary timeout diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index e4231c1598..f80ce2810c 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess' @@ -22,8 +22,8 @@ import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DI const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url)) -/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */ -const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent +/** A parent Agent stub. The ACP backend reads exactly one thing off it: the session header's cwd (the workspace its child inherits). */ +const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent function request(text = 'p', signal = new AbortController().signal) { return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal } @@ -115,6 +115,184 @@ describe('buildChildEnv', () => { }) }) +describe('cwd resolution', () => { + it('falls back to the parent session cwd for the child process AND its ACP session', async () => { + // realpath: on macOS `tmpdir()` sits behind a symlink (/var → /private/var), + // and the child reports its REAL process.cwd() — compare canonical paths. + const workdir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-'))) + try { + const ctx = await setup({ MOCK_ECHO_CWD: '1' }) + const parent = { id: 'parent', session: { header: { cwd: workdir } } } as unknown as Agent + const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }) + const result = await run.result + await run.dispose() + // Line 1: where the child process actually ran; line 2: the workspace the + // backend announced in `session/new`. Both must be the parent's workspace. + expect(text(result.output)).toBe(`${workdir}\n${workdir}`) + } finally { + rmSync(workdir, { recursive: true, force: true }) + } + }) + + it('rejects before spawning when neither config.cwd nor the parent session provides one', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'acp-no-cwd-')) + const sentinel = join(tmp, 'spawned') + try { + const ctx = new Context() + await ctx.plugin(SubagentService) + // A command that would create the sentinel if the child were ever spawned. + await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) + const parent = { id: 'parent', session: { header: {} } } as unknown as Agent + await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) + .rejects.toThrow('no working directory') + // Resolution failed BEFORE the process boundary — nothing was launched. + expect(existsSync(sentinel)).toBe(false) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('prefers the configured cwd override to the parent session cwd', async () => { + const configured = realpathSync(mkdtempSync(join(tmpdir(), 'acp-cfg-cwd-'))) + const parentDir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-'))) + try { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: process.execPath, + args: [mockServer], + cwd: configured, + permission: 'reject', + env: { MOCK_ECHO_CWD: '1' }, + }) + const parent = { id: 'parent', session: { header: { cwd: parentDir } } } as unknown as Agent + const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }) + const result = await run.result + await run.dispose() + expect(text(result.output)).toBe(`${configured}\n${configured}`) + } finally { + rmSync(configured, { recursive: true, force: true }) + rmSync(parentDir, { recursive: true, force: true }) + } + }) + + it('resolves a relative config cwd against the launch directory at load', async () => { + // The child process AND its announced ACP session cwd must both get the + // ABSOLUTE form — DSH's own ACP server rejects a relative session cwd, and + // deferring resolution to spawn would hide the launch-dir dependency. + const relative = 'packages/subagent/subagent-acp' + const absolute = resolve(relative) + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: process.execPath, + args: [mockServer], + cwd: relative, + permission: 'reject', + env: { MOCK_ECHO_CWD: '1' }, + }) + const run = await ctx.subagents.start('acp', request()) + const result = await run.result + await run.dispose() + expect(text(result.output)).toBe(`${realpathSync(absolute)}\n${absolute}`) + }) + + it('rejects an empty config cwd at load', async () => { + // `path.resolve('')` is the process cwd, so an empty string would silently + // reintroduce the launch-directory fallback this resolution removed. + const ctx = new Context() + await ctx.plugin(SubagentService) + await expect(ctx.plugin(acp, { + providerName: 'acp', + command: 'true', + args: [], + cwd: '', + permission: 'reject', + env: {}, + })).rejects.toThrow('config cwd must not be empty') + await ctx.fiber.dispose() + }) + + it('rejects a config cwd directory without search permission at load', async () => { + // statSync().isDirectory() is true for a mode-600 directory, but a + // subprocess cwd needs SEARCH permission — spawn would fail EACCES. + const tmp = mkdtempSync(join(tmpdir(), 'acp-noexec-')) + chmodSync(tmp, 0o600) + try { + const ctx = new Context() + await ctx.plugin(SubagentService) + await expect(ctx.plugin(acp, { + providerName: 'acp', + command: 'true', + args: [], + cwd: tmp, + permission: 'reject', + env: {}, + })).rejects.toThrow('not an accessible directory') + await ctx.fiber.dispose() + } finally { + chmodSync(tmp, 0o700) + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('rejects a config cwd that is not an accessible directory at load', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await expect(ctx.plugin(acp, { + providerName: 'acp', + command: 'true', + args: [], + cwd: '/nonexistent/acp-child-workspace', + permission: 'reject', + env: {}, + })).rejects.toThrow('not an accessible directory') + await ctx.fiber.dispose() + }) + + it('rejects a parent session cwd that is not absolute', async () => { + // SessionHeader documents cwd as absolute; a relative value here is a broken + // header, and resolving it against the server process cwd would silently + // re-introduce the launch-directory dependency this resolution removes. + const ctx = await setup({}) + const parent = { id: 'parent', session: { header: { cwd: 'relative/workspace' } } } as unknown as Agent + await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) + .rejects.toThrow('must be an absolute path') + }) + + it('rejects a parent session cwd that names a FILE, not a directory', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'acp-file-cwd-')) + const file = join(tmp, 'a-file') + writeFileSync(file, 'x') + try { + const ctx = await setup({}) + const parent = { id: 'parent', session: { header: { cwd: file } } } as unknown as Agent + await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) + .rejects.toThrow('not an accessible directory') + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('rejects a parent session cwd that is not an accessible directory, before spawning', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'acp-bad-parent-cwd-')) + const sentinel = join(tmp, 'spawned') + try { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) + const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent + await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) + .rejects.toThrow('not an accessible directory') + expect(existsSync(sentinel)).toBe(false) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) +}) + describe('dsh-subagent-acp', () => { it('drives child processes with parent-unique run ids and returns streamed output', async () => { const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn', MOCK_SESSION_ID: 'acp-child-session' }) 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..e3d65701b1 --- /dev/null +++ b/packages/subagent/subagent-fork/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/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-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..7b8bfc36e2 --- /dev/null +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/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 e82c1ad028..4826931e9d 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-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..0ba0182f9f --- /dev/null +++ b/packages/subagent/subagent-spawn/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/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/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..c273ce5209 --- /dev/null +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..3c350c13a1 --- /dev/null +++ b/packages/subagent/subagent/src/invariant.ts @@ -0,0 +1,91 @@ +/** Package-owned subagent registry and lifecycle invariants. @module @deepseek-ai/dsh-subagent/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** 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 the subagent 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/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 1b1645d89b..7031bd0ad3 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -56,7 +56,10 @@ export interface SubagentStartRequest { * The spawning ("parent") agent — the one whose tool call started this * subagent. REQUIRED: in-process backends read `parent.session.header` for * the working directory, the `parentSession` lineage to stamp on the child, - * and the parent's delegation depth. Out-of-process backends (ACP) ignore it. + * and the parent's delegation depth. The out-of-process backend (ACP) reads + * exactly one field — the session header's cwd, the child's workspace when + * no deployment `cwd` override is configured; nothing else crosses the + * process boundary. */ readonly parent: Agent /** 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/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..bd30f4c563 --- /dev/null +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 9cf18e7824..e5e715238a 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..e94876100e --- /dev/null +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..33ee4474f9 --- /dev/null +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 aef85dbb02..e4866212b6 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -1,65 +1,83 @@ # 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. Every workspace package publishes a `./invariant` companion that registers its exact npm package name. -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 complete provenance and positional replacement, restricts `tool/result` replacement to one current result's `content`, 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 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. -## 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. 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 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 dependency to the service. + +Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates complete provenance and positional replacement, restricts `tool/result` replacement to one current result's `content`, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The `dsh-session` invariant companion checks the remaining cross-record rules that Session does not own. + +## Package companions + +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. + +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. + +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-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-goal`, `dsh-goal-session` | Durable goal source/content agreement, revision and lifecycle transitions, timestamps, sequential admitted rounds, and reconstructed continuation prompts. | +| `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 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. + +`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 ```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. +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. -## 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. -- **an appended `tool/result` needs a prior `tool/call`** — fresh `surfaceOp: 'append'` results name the open step and consume its pending call. A Session-validated replacement is a turn-enclosed rewrite, not another execution. A `tool/call` may still have no result when the execution pipeline throws. - -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 Agent Note](../../../.agents/notes/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](../../../.agents/notes/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. +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 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 mutable snapshots without altering prompts, messages, schemas, streams, or tool results. #### KV Cache effect -None; this package neither assembles nor sends a provider request. +None; invariant checks do not assemble or send provider requests. ## 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. +- 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/package.json b/packages/support/invariants/package.json index 9ca1e0aab1..d52dd0a14d 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", @@ -11,33 +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-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-goal": "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 91b4da4566..2e6194b59b 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,419 +1,200 @@ /** - * 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. + * Every workspace package registers checks from a `./invariant` companion; + * 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 checks 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, or a promise settling after asynchronous checks finish. */ - pendingCalls: Set + (ctx: Context, fail: InvariantFailure): void | Promise + /** 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 Agent Note). 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': { - // Session has already validated a provenance-backed content rewrite. - // It is durable turn work, not a second execution of the original call. - if (event.surfaceOp !== 'append') { - if (trace.openTurn === null) { - throw new InvariantError( - 'tool/result surface replacement appended outside any open turn', - ) - } - break - } - 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 Agent Note): 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 - listener or startup-check 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 Agent Note): 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) + }) + ) + try { + const child = ctx.plugin(installer.inject === undefined + ? installInvariant + : Object.assign(installInvariant, { inject: installer.inject })) + + try { + await child + } catch (error) { + await child.dispose() + 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) { + 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/invariant.ts b/packages/support/invariants/src/invariant.ts new file mode 100644 index 0000000000..7780e987f5 --- /dev/null +++ b/packages/support/invariants/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-invariants' + +/** Cordis companion plugin name. */ +export const name = 'invariants-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['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. + * @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/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts deleted file mode 100644 index a007ab4174..0000000000 --- a/packages/support/invariants/src/scoped-events.generated.ts +++ /dev/null @@ -1,74 +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-goal' -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/cancel-requested': adapt<'agent/cancel-requested'>(args => args[0]), - '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), - 'goal/changed': adapt<'goal/changed'>(args => args[0]), - '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 4f066eff8c..0000000000 --- a/packages/support/invariants/tests/invariants.spec.ts +++ /dev/null @@ -1,965 +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 Agent Note). - 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 Agent Note). - 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('keeps fresh tool-result appends open-step and pending-call checked', 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('tool/result', { - turn: 1, - step: 1, - callId: CallId('closed'), - content: [], - isError: false, - }, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/) - }) - - 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', () => { - async function toolResultRewriteFixture(openRewriteTurn = true) { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const unrelated = session.append('user/message', { - content: [{ type: 'text', text: 'request' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('tool/call', { - turn: 1, - step: 1, - callId: CallId('rewrite'), - name: 'echo', - arguments: '{}', - }) - const originalData = { - turn: 1, - step: 1, - callId: CallId('rewrite'), - content: [{ type: 'text' as const, text: 'original' }], - isError: true, - error: { name: 'ExitError', code: 'EXIT_1' }, - meta: { presentation: { kind: 'terminal', output: 'full output' } }, - futureField: { nested: ['preserve', 1] }, - } - const original = session.append('tool/result', originalData, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - if (openRewriteTurn) { - session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) - } - return { session, unrelated, original } - } - - 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('treats a provenance-backed tool-result replacement as a turn-enclosed rewrite', async () => { - const { session, original } = await toolResultRewriteFixture() - - expect(() => session.append('tool/result', { - ...original.data, - content: [{ type: 'text', text: 'pruned' }], - }, { - surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, - sourceEventSeqs: [original.seq], - })).not.toThrow() - }) - - it('rejects a tool-result replacement outside a turn', async () => { - const { session, original } = await toolResultRewriteFixture(false) - - expect(() => session.append('tool/result', { - ...original.data, - content: [{ type: 'text', text: 'pruned' }], - }, { - surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, - sourceEventSeqs: [original.seq], - })).toThrow(/outside any open turn/) - }) - - it('rejects a tool-result replacement targeting an unrelated current node', async () => { - const { session, unrelated, original } = await toolResultRewriteFixture() - expect(() => session.append('tool/result', { - ...original.data, - content: [{ type: 'text', text: 'forged' }], - }, { - surfaceOp: { op: 'replace', start: unrelated.seq, end: unrelated.seq }, - sourceEventSeqs: [unrelated.seq], - })).toThrow(/must target a current tool\/result/) - }) - - it('rejects a multi-node tool-result replacement even with complete provenance', async () => { - const { session, unrelated, original } = await toolResultRewriteFixture() - expect(() => session.append('tool/result', { - ...original.data, - content: [{ type: 'text', text: 'forged' }], - }, { - surfaceOp: { op: 'replace', start: unrelated.seq, end: original.seq }, - sourceEventSeqs: [unrelated.seq, original.seq], - })).toThrow(/must rewrite exactly one current node/) - }) - - it.each([ - ['callId', { callId: CallId('forged') }], - ['turn', { turn: 2 }], - ['step', { step: 2 }], - ['error', { error: { name: 'ExitError', code: 'DIFFERENT' } }], - ['meta', { meta: { presentation: { kind: 'generic' } } }], - ['future data', { futureField: { nested: ['changed'] } }], - ])('rejects a content rewrite with altered %s', async (_label, altered) => { - const { session, original } = await toolResultRewriteFixture() - expect(() => session.append('tool/result', { - ...original.data, - ...altered, - content: [{ type: 'text', text: 'pruned' }], - }, { - surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, - sourceEventSeqs: [original.seq], - })).toThrow(/may change only content/) - }) - - 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')]], - ['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]], - ['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..9000fb8955 --- /dev/null +++ b/packages/support/invariants/tests/service.spec.ts @@ -0,0 +1,310 @@ +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('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() + 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 + 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 8fefb2d7eb..f0063ae28d 100644 --- a/packages/support/invariants/tsconfig.json +++ b/packages/support/invariants/tsconfig.json @@ -15,31 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../../llm/llm" - }, - { - "path": "../../core/session" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../goal/goal" - }, - { - "path": "../../core/scope" - }, - { - "path": "../../core/system-prompt" - }, - { - "path": "../../ui/user-approval" - }, - { - "path": "../../core/tools" - }, - { - "path": "../../subagent/subagent" + "path": "../../../vendor/schemastery" } ] } 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/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/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/src/invariant.ts b/packages/support/llm-replay/src/invariant.ts new file mode 100644 index 0000000000..36a3f8eeca --- /dev/null +++ b/packages/support/llm-replay/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/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() 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..1e3cc54b81 --- /dev/null +++ b/packages/support/loader-smoke/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..a633213607 --- /dev/null +++ b/packages/tasks/tasks/src/invariant.ts @@ -0,0 +1,57 @@ +/** Package-owned background-task snapshot invariants. @module @deepseek-ai/dsh-tasks/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** 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 the task-registry 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/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/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..cedad9dc1c --- /dev/null +++ b/packages/tasks/tool-tasks/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..ddc3b3966e --- /dev/null +++ b/packages/timeout/timeout-policy/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..d353c80f77 --- /dev/null +++ b/packages/todo/tool-todo/src/invariant.ts @@ -0,0 +1,61 @@ +/** Package-owned durable todo-snapshot invariants. @module @deepseek-ai/dsh-tool-todo/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** 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`) +} + +/* 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] + validateEvent(event, fail) + }, { global: true }) +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ + +/** + * Register the todo 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/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts new file mode 100644 index 0000000000..abfcd74b29 --- /dev/null +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +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(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) + 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() + }) + + 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/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 9c4fefaa01..1eafb7fc70 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" @@ -30,6 +35,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-permission": "^0.0.1", diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 62455223a4..47a479a56b 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -44,10 +44,15 @@ import { type Stream, type StopReason, } from '@agentclientprotocol/sdk' -import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { + installAgentLlmTarget, + type Agent, + type AgentLlmTarget as LlmTarget, + type AgentLlmTargetRef as LlmTargetRef, +} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-commands' import { SessionId } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. @@ -258,19 +263,6 @@ export const Config: Schema = Schema.object({ model: Schema.string(), }) -/** Provider/model pair selected for one ACP session. */ -interface LlmTarget { - provider: string - model: string -} - -/** Mutable target shared by one agent's scoped assembly and request listeners. */ -interface LlmTargetRef { - current: LlmTarget | undefined - /** Step snapshot captured by prompt assembly so target switches cannot split prompt and request. */ - assembled: LlmTarget | undefined -} - /** One resolved ACP model selector plus its opaque value lookup. */ interface ModelDirectory { option: Extract | undefined @@ -338,32 +330,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const logged = agent.session.requestHeader()?.config if (logged !== undefined) target.current = { provider: logged.provider, model: logged.model } - // Capture once at assembly entry and apply the same pair after downstream - // prompt listeners. A selector change during async assembly therefore takes - // effect on the following step instead of splitting {{model}} from routing. - agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => { - const selected = target.current - const assembled = await next() - target.assembled = selected - if (selected === undefined) return assembled - return { - ...assembled, - variables: { - ...assembled.variables, - provider: selected.provider, - model: selected.model, - }, - } - }) - agentCtx.on('agent/request', async (_agent, _turn, _step, _callConfig, next): Promise => { - const resolved = await next() - const selected = target.assembled - return selected === undefined ? resolved : { - ...resolved, - provider: selected.provider, - model: selected.model, - } - }) + installAgentLlmTarget(agentCtx, target) } /** Opaque ACP value preserving both routing dimensions. */ diff --git a/packages/ui/acp/src/invariant.ts b/packages/ui/acp/src/invariant.ts new file mode 100644 index 0000000000..fdefcf291e --- /dev/null +++ b/packages/ui/acp/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-acp' + +/** Cordis companion plugin name. */ +export const name = 'acp-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/tests/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts index 29fb06b28c..de5fd93941 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/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 91e1272212..e0b3a7fe2c 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -52,6 +52,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..0dacba6e40 --- /dev/null +++ b/packages/ui/app-boot/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-app-boot`. + * @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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/commands/package.json b/packages/ui/commands/package.json index ef7e54e1c1..0a777d22d8 100644 --- a/packages/ui/commands/package.json +++ b/packages/ui/commands/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-scope": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@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/ui/commands/src/invariant.ts b/packages/ui/commands/src/invariant.ts new file mode 100644 index 0000000000..87751d7cb4 --- /dev/null +++ b/packages/ui/commands/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-commands`. + * @module @deepseek-ai/dsh-commands/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-commands' + +/** Cordis companion plugin name. */ +export const name = 'commands-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: registry notifications intentionally hide mutation details and contain + * observers, so list/find self-comparisons would duplicate implementation rather than detect drift. + */ +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/commands/tsconfig.json b/packages/ui/commands/tsconfig.json index 478cc26479..8f0448250f 100644 --- a/packages/ui/commands/tsconfig.json +++ b/packages/ui/commands/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/scope" + }, + { + "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..1a3c9b053b --- /dev/null +++ b/packages/ui/jsonrpc/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc' + +/** Cordis companion plugin name. */ +export const name = 'jsonrpc-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 2d78f52a78..3ec4cc7685 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-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -34,6 +40,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/ui/permission/src/invariant.ts b/packages/ui/permission/src/invariant.ts new file mode 100644 index 0000000000..b1290b7307 --- /dev/null +++ b/packages/ui/permission/src/invariant.ts @@ -0,0 +1,39 @@ +/** Package-owned permission-preset event invariants. @module @deepseek-ai/dsh-permission/invariant */ + +import type { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-permission' + +/** Cordis companion plugin name. */ +export const name = 'permission-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** 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] + validateEvent(ctx, event, fail) + }, { global: true }) +}, { inject: ['permission', 'sessions'] }) + +/** + * Register the permission 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/ui/permission/tests/invariant.spec.ts b/packages/ui/permission/tests/invariant.spec.ts new file mode 100644 index 0000000000..9b903dc6a5 --- /dev/null +++ b/packages/ui/permission/tests/invariant.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { Context, Service } from 'cordis' +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' + +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(SessionStore) + await ctx.plugin(PermissionProbe) + await ctx.plugin(InvariantService, { enabled: true }) + 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"/) + }) + + 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/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index a203bd5082..864f25629d 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/permission/tsconfig.json b/packages/ui/permission/tsconfig.json index fa31f71f69..0971399f53 100644 --- a/packages/ui/permission/tsconfig.json +++ b/packages/ui/permission/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../user-approval" + }, + { + "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..140bbd79c5 --- /dev/null +++ b/packages/ui/tool-ask-user/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/README.md b/packages/ui/tui/README.md index 550652c22d..18fda35648 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -6,13 +6,15 @@ The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feat Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification. -This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `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. +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 as keyboard-driven overlays. 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. 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. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. + +`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. ## Config @@ -21,10 +23,13 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot | `welcome` | `ready.` | Header subtitle | | `sessionId` | `main` | Exact shared agent/session identity driven by the terminal | | `showReasoning` | `true` | Render reasoning blocks | -| `maxToolOutputLines` | `12` | Collapsed tool-card output limit | -| `maxQuestionOptions` | `8` | Visible options in a question overlay | -| `questionDialogWidth` | `72` | Question-overlay width in columns | -| `questionDialogMaxHeight` | `20` | Question-overlay maximum rows | +| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | +| `maxQuestionOptions` | `8` | Visible options in a question panel | +| `maxModelOptions` | `8` | Visible models in the model selector | +| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | +| `questionDialogMaxHeight` | `20` | Question-panel maximum rows | +| `modelDialogWidth` | `72` | Model-selector width in columns | +| `modelDialogMaxHeight` | `20` | Model-selector maximum rows | | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Terminal window title | @@ -36,14 +41,14 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot welcome: 'Coding agent ready.' sessionId: main-session-123 showReasoning: true - maxToolOutputLines: 12 + maxToolOutputLines: 6 ``` Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. ## Color -The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block, and the question overlay's active row uses reverse video; both are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. +The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block; the question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. ## Model Experience @@ -61,6 +66,20 @@ Submitted text is retained under the agent loop's normal session-history and com Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### Session model selection + +#### What the model sees + +The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model pair in both prompt variables and request routing. + +#### Token effect + +The selector adds no messages. A target change may alter interpolated system-prompt text and sends subsequent requests to the selected model. + +#### KV Cache effect + +Changing provider or model enters that target's cache domain; no cache reuse across distinct targets is assumed. + ### Interactive user-question answers #### What the model sees diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 136acde7bc..b82e4eec48 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" @@ -25,9 +30,12 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-commands": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -41,10 +49,12 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index cbb3593209..137ef7a264 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -13,12 +13,12 @@ import { Editor, Input, Key, - Loader, Markdown, Spacer, Text, TUI, ProcessTerminal, + SelectList, matchesKey, truncateToWidth, visibleWidth, @@ -33,11 +33,23 @@ import { } from '@earendil-works/pi-tui' import type { Context } from 'cordis' import z from 'schemastery' -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import { + installAgentLlmTarget, + type Agent, + type AgentLlmTarget, + type AgentLlmTargetRef, + type AgentStatus, +} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' +import type {} from '@deepseek-ai/dsh-token-meter' import type {} from '@deepseek-ai/dsh-commands' import { errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { + ContentBlock, + LlmModelInfo, + StreamChunk, + TokenUsage, +} from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session' import type { @@ -56,20 +68,26 @@ import { } from '@deepseek-ai/dsh-user-interaction' export const name = 'ui-tui' -export const inject = ['agents', 'commands', 'userInteraction', 'tools'] +export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] /** Presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { /** Render model reasoning blocks. */ showReasoning?: boolean - /** Maximum tool-output lines shown before the card is collapsed. */ + /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number - /** Maximum options visible at once in a user-question dialog. */ + /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number - /** User-question dialog width in terminal columns. */ + /** Maximum models visible at once in the model selector. */ + maxModelOptions?: number + /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number - /** User-question dialog maximum height in terminal rows. */ + /** User-question panel maximum height in terminal rows. */ questionDialogMaxHeight?: number + /** Model-selector width in terminal columns. */ + modelDialogWidth?: number + /** Model-selector maximum height in terminal rows. */ + modelDialogMaxHeight?: number /** Show the terminal's hardware cursor at the pi editor's IME marker. */ showHardwareCursor?: boolean /** Apply the built-in ANSI color palette. */ @@ -79,10 +97,13 @@ export interface TuiConfig { } const showReasoningSchema = z.boolean().default(true) -const maxToolOutputLinesSchema = z.number().step(1).min(1).default(12) +const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) -const questionDialogWidthSchema = z.number().step(1).min(20).default(72) +const maxModelOptionsSchema = z.number().step(1).min(1).default(8) +const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const modelDialogWidthSchema = z.number().step(1).min(20).default(72) +const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const showHardwareCursorSchema = z.boolean().default(false) const colorSchema = z.boolean().default(true) const titleSchema = z.string().default('DeepSeek Harness') @@ -92,8 +113,11 @@ export const TuiConfigSchema: z = z.object({ showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, + maxModelOptions: maxModelOptionsSchema, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, + modelDialogWidth: modelDialogWidthSchema, + modelDialogMaxHeight: modelDialogMaxHeightSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, title: titleSchema, @@ -113,8 +137,11 @@ export const Config: z = z.object({ showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, + maxModelOptions: maxModelOptionsSchema, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, + modelDialogWidth: modelDialogWidthSchema, + modelDialogMaxHeight: modelDialogMaxHeightSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, title: titleSchema, @@ -125,8 +152,11 @@ export interface ResolvedTuiConfig { showReasoning: boolean maxToolOutputLines: number maxQuestionOptions: number + maxModelOptions: number questionDialogWidth: number questionDialogMaxHeight: number + modelDialogWidth: number + modelDialogMaxHeight: number showHardwareCursor: boolean color: boolean title: string @@ -138,6 +168,8 @@ export interface TuiRuntime { terminal: Terminal /** Exit hook used by terminal shutdown or a target-agent startup failure. */ exit(code: number): void + /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ + now?(): number } /** @@ -149,10 +181,13 @@ export interface TuiRuntime { export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig { return { showReasoning: config?.showReasoning ?? true, - maxToolOutputLines: config?.maxToolOutputLines ?? 12, + maxToolOutputLines: config?.maxToolOutputLines ?? 6, maxQuestionOptions: config?.maxQuestionOptions ?? 8, - questionDialogWidth: config?.questionDialogWidth ?? 72, + maxModelOptions: config?.maxModelOptions ?? 8, + questionDialogWidth: config?.questionDialogWidth ?? 200, questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, + modelDialogWidth: config?.modelDialogWidth ?? 72, + modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, showHardwareCursor: config?.showHardwareCursor ?? false, color: config?.color ?? true, title: config?.title ?? 'DeepSeek Harness', @@ -253,6 +288,13 @@ function selectTheme(palette: Palette): SelectListTheme { } } +function dialogSelectTheme(palette: Palette): SelectListTheme { + return { + ...selectTheme(palette), + selectedText: text => palette.selected(palette.accent(text)), + } +} + function contentText(content: readonly ContentBlock[]): string { const parts: string[] = [] for (const block of content) { @@ -284,11 +326,52 @@ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning' .join('\n\n') } +interface ModelChoice extends AgentLlmTarget { + modelName: string + description?: string +} + +function targetLabel(target: AgentLlmTarget): string { + return `${target.provider}/${target.model}` +} + +function initialTarget(agent: Agent): AgentLlmTarget | undefined { + const logged = agent.session.requestHeader()?.config + if (logged !== undefined) return { provider: logged.provider, model: logged.model } + if (agent.options.provider === undefined || agent.options.model === undefined) return undefined + return { provider: agent.options.provider, model: agent.options.model } +} + +async function readModelChoices( + ctx: Context, + current: AgentLlmTarget | undefined, +): Promise { + const providers = ctx.llm.listProviders() + const groups = await Promise.all(providers.map(async (provider) => { + const advertised = await ctx.llm.listModels(provider.id) + const models: LlmModelInfo[] = [...advertised] + if ( + current?.provider === provider.id + && !models.some(model => model.id === current.model) + ) { + models.push({ provider: provider.id, id: current.model, name: current.model }) + } + return models.map((model): ModelChoice => ({ + provider: provider.id, + model: model.id, + modelName: model.name, + ...model.description === undefined ? {} : { description: model.description }, + })) + })) + return groups.flat() +} + class HeaderComponent implements Component { constructor( private readonly agent: Agent, private readonly welcome: string, private readonly palette: Palette, + private readonly currentModel: () => string | undefined, ) {} invalidate(): void {} @@ -296,7 +379,7 @@ class HeaderComponent implements Component { render(width: number): string[] { const usable = Math.max(1, width - 4) const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}` - const model = displayText(this.agent.options.model ?? 'model unset') + const model = displayText(this.currentModel() ?? 'model unset') const detail = `${model} • ${displayText(this.agent.session.id)}` const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`) const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`) @@ -508,9 +591,15 @@ class ToolCardComponent implements Component { const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓') const body = this.renderBody() const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '') + const headLines = Math.ceil(this.maxOutputLines / 2) + const tailLines = this.maxOutputLines - headLines const visibleBody = this.expanded || body.length <= this.maxOutputLines ? body - : [...body.slice(0, this.maxOutputLines), this.palette.dim(`… ${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)] + : [ + ...body.slice(0, headLines), + this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`), + ...body.slice(body.length - tailLines), + ] const barFn = this.result === undefined ? this.palette.warning : isError ? this.palette.error : this.palette.success @@ -647,19 +736,41 @@ class FooterComponent implements Component { private readonly toolsExpanded: () => boolean, private readonly showReasoning: () => boolean, private readonly tokens: () => { input: number; output: number }, + private readonly currentModel: () => string | undefined, + private readonly contextPercent: () => number | undefined, + private readonly runningSeconds: () => number, ) {} invalidate(): void {} render(width: number): string[] { + if (this.agent.status === 'running') { + const interrupt = this.palette.dim('esc interrupt') + const activityAvailable = Math.max(0, width - visibleWidth(interrupt) - 1) + const activity = truncateToWidth(this.palette.accent(`◒ Working · ${this.runningSeconds()}s`), activityAvailable, '') + const gap = ' '.repeat(Math.max(0, width - visibleWidth(activity) - visibleWidth(interrupt))) + return [`${activity}${gap}${interrupt}`] + } const { input, output } = this.tokens() - const left = `${formatCwd(this.agent.session.header.cwd)} ↑${formatTokens(input)} ↓${formatTokens(output)}` - const right = `${this.agent.status} reasoning:${this.showReasoning() ? 'on' : 'off'} tools:${this.toolsExpanded() ? 'expanded' : 'compact'}` - const leftStyled = this.palette.dim(left) - const available = Math.max(0, width - visibleWidth(left) - 2) - const rightClipped = truncateToWidth(right, available, '') - const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped))) - return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')] + const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}` + const model = displayText(this.currentModel() ?? 'model unset') + const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})` + 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) { + const compact = truncateToWidth(compactRight, width, '') + return [`${' '.repeat(Math.max(0, width - visibleWidth(compact)))}${this.palette.dim(compact)}`] + } + const rightAvailable = width - visibleWidth(counters) - 1 + const right = visibleWidth(fullRight) <= rightAvailable ? fullRight : compactRight + const rightClipped = truncateToWidth(right, rightAvailable, '') + const cwdAvailable = Math.max(0, width - visibleWidth(counters) - visibleWidth(rightClipped) - 3) + const cwd = truncateToWidth(formatCwd(this.agent.session.header.cwd), cwdAvailable, '') + const left = [cwd, counters].filter(Boolean).join(' ') + const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(rightClipped))) + return [`${this.palette.dim(left)}${gap}${this.palette.dim(rightClipped)}`] } } @@ -668,6 +779,76 @@ interface QuestionSelection { custom?: string } +function renderDialog( + title: string, + body: readonly string[], + width: number, + palette: Palette, +): string[] { + const innerWidth = Math.max(1, width - 4) + const topLabel = ` ${displayText(title)} ` + const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` + const lines: string[] = [palette.accent(top)] + for (const line of body) { + const clipped = truncateToWidth(line, innerWidth, '') + lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`) + } + lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) + return lines +} + +class ModelDialog implements Component { + private readonly list: SelectList + + constructor( + choices: readonly ModelChoice[], + current: AgentLlmTarget | undefined, + maxVisible: number, + private readonly palette: Palette, + done: (choice: ModelChoice) => void, + cancel: () => void, + ) { + this.list = new SelectList(choices.map(choice => ({ + value: targetLabel(choice), + label: displayText(targetLabel(choice)), + description: [ + displayText(choice.modelName), + ...choice.description === undefined ? [] : [displayText(choice.description)], + ...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [], + ].join(' — '), + })), maxVisible, dialogSelectTheme(palette)) + const currentIndex = current === undefined + ? 0 + : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model) + this.list.setSelectedIndex(currentIndex) + this.list.onSelect = (item) => { + const selected = choices.find(choice => targetLabel(choice) === item.value) + /* v8 ignore next -- SelectList only returns values built from `choices`. */ + if (selected === undefined) return + done(selected) + } + this.list.onCancel = cancel + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + this.list.handleInput(data) + this.invalidate() + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + return renderDialog('Select model', [ + ...this.list.render(innerWidth), + '', + this.palette.dim('↑/↓ navigate • Enter select • Esc cancel'), + ], width, this.palette) + } +} + class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() @@ -679,6 +860,9 @@ class QuestionDialog implements Component, Focusable { constructor( private readonly question: AskUserQuestionItem, + private readonly position: number, + private readonly total: number, + private readonly unanswered: number, private readonly maxVisible: number, private readonly palette: Palette, private readonly done: (selection: QuestionSelection) => void, @@ -719,11 +903,11 @@ class QuestionDialog implements Component, Focusable { } else if (matchesKey(data, Key.enter)) { const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] if (indices.length === 0) { - this.error = 'Select at least one option, or press C for a custom answer.' + this.error = 'Select at least one option, or press Tab for a custom answer.' return } this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) - } else if (data.toLowerCase() === 'c') { + } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' this.error = '' } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { @@ -743,16 +927,13 @@ class QuestionDialog implements Component, Focusable { render(width: number): string[] { this.input.focused = this.focused const innerWidth = Math.max(1, width - 4) - const title = displayText(this.question.header ?? 'Question') - const topLabel = ` ${title} ` - const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` - const lines: string[] = [this.palette.accent(top)] - const push = (line: string): void => { - const clipped = truncateToWidth(line, innerWidth, '') - lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`) - } - for (const line of wrapTextWithAnsi(this.palette.bold(displayText(this.question.question)), innerWidth)) push(line) - push('') + const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` + const lines = [ + this.palette.muted(header), + ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), + '', + ] + const push = (line: string): void => { lines.push(line) } if (this.mode === 'custom') { for (const line of this.input.render(innerWidth)) push(line) push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) @@ -763,27 +944,45 @@ class QuestionDialog implements Component, Focusable { options.length - this.maxVisible, )) const end = Math.min(options.length, start + this.maxVisible) + const optionRows = options.slice(start, end).map((option, offset) => { + const index = start + offset + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` + }) + const descriptionColumn = Math.min( + Math.max(...optionRows.map(row => visibleWidth(row))) + 2, + Math.max(1, Math.floor(innerWidth * 0.55)), + ) for (let index = start; index < end; index += 1) { // `index < end <= options.length`; the options array is borrowed immutably for this dialog. const option = options[index] as NonNullable[number] - const cursor = index === this.selectedIndex ? this.palette.accent('›') : ' ' const mark = this.question.multiSelect - ? this.selected.has(index) ? this.palette.success('[x]') : '[ ]' - : index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○') - const description = option.description - ? this.palette.muted(` — ${displayText(option.description)}`) + ? this.selected.has(index) ? '[x] ' : '[ ] ' : '' - const line = `${cursor} ${mark} ${displayText(option.label)}${description}` - push(index === this.selectedIndex ? this.palette.selected(line) : line) + const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` + const leftStyled = index === this.selectedIndex + ? this.palette.bold(this.palette.accent(left)) + : left + const description = option.description === undefined + ? '' + : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}` + push(`${leftStyled}${description}`) } if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) - push(this.palette.dim(this.question.multiSelect - ? '↑↓ navigate • Space toggle • Enter submit • C custom • Esc cancel' - : '↑↓ navigate • Enter select • C custom • Esc cancel')) + const hint = this.palette.dim(this.question.multiSelect + ? 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt' + : 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt') + for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) } - if (this.error) push(this.palette.error(this.error)) - lines.push(this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) - return lines + if (this.error) { + for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) + } + return ['', ...lines, ''].map((line) => { + const clipped = truncateToWidth(line, innerWidth, '') + return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` + }) } } @@ -839,7 +1038,6 @@ export function createTuiChat( const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() - const statusContainer = new Container() const editor = new Editor(ui, { borderColor: palette.dim, selectList: selectTheme(palette), @@ -848,7 +1046,8 @@ export function createTuiChat( let showReasoning = resolved.showReasoning let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined - let statusLoader: Loader | undefined + let runningStartedAt: number | undefined + let statusTicker: ReturnType | undefined let disposed = false let shuttingDown: Promise | undefined const tokens = sessionTokens(agent.session) @@ -858,13 +1057,32 @@ export function createTuiChat( const questionQueue: PendingQuestion[] = [] const commandControllers = new Set() 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() const welcome = config.welcome ?? 'ready.' - const header = new HeaderComponent(agent, welcome, palette) - const footer = new FooterComponent(agent, palette, () => toolsExpanded, () => showReasoning, () => tokens) + const header = new HeaderComponent(agent, welcome, palette, () => target.current?.model) + const footer = new FooterComponent( + agent, + palette, + () => toolsExpanded, + () => showReasoning, + () => tokens, + () => target.current?.model, + () => 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) ui.addChild(chat) - ui.addChild(statusContainer) todoContainer.addChild(todo) ui.addChild(todoContainer) ui.addChild(editor) @@ -884,10 +1102,120 @@ export function createTuiChat( requestRender() } + 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.`) + } + + const showModelSelector = (choices: readonly ModelChoice[]): void => { + const current = target.current === undefined ? 'unset' : targetLabel(target.current) + if (choices.length === 0) { + appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning') + return + } + modelOverlay?.hide() + modelOverlay = undefined + const close = (): void => { + modelOverlay?.hide() + modelOverlay = undefined + requestRender() + } + const dialog = new ModelDialog( + choices, + target.current, + resolved.maxModelOptions, + palette, + (selected) => { + close() + selectModel(selected) + }, + close, + ) + modelOverlay = ui.showOverlay(dialog, { + width: resolved.modelDialogWidth, + maxHeight: resolved.modelDialogMaxHeight, + anchor: 'center', + margin: 1, + }) + requestRender() + } + + const handleModelCommand = async (raw: string): Promise => { + const choices = await readModelChoices(ctx, target.current) + if (disposed) return + const argument = raw.trim() + if (argument === '') { + showModelSelector(choices) + return + } + const parts = argument.split(/\s+/u) + if (parts.length > 2) { + appendNotice('Usage: /model [provider/]model', 'warning') + return + } + + let matches: ModelChoice[] + if (parts.length === 2) { + matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1]) + } else { + const value = argument + const qualified = choices.filter(choice => targetLabel(choice) === value) + matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value) + } + if (matches.length === 0) { + appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning') + return + } + if (matches.length > 1) { + appendNotice(`Model "${argument}" is advertised by multiple providers; use /model /.`, 'warning') + return + } + const selected = matches[0] + /* v8 ignore next -- a non-empty matches array always has index zero. */ + if (selected === undefined) return + selectModel(selected) + } + + const queueModelCommand = (raw: string): void => { + modelCommands = modelCommands.then(async () => { + await handleModelCommand(raw) + }).catch((error: unknown) => { + if (!disposed) appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error') + }) + } + const clearStatus = (): void => { - statusLoader?.stop() - statusLoader = undefined - statusContainer.clear() + if (statusTicker !== undefined) clearInterval(statusTicker) + statusTicker = undefined + runningStartedAt = undefined runtime.terminal.setProgress(false) } @@ -895,8 +1223,9 @@ export function createTuiChat( clearStatus() editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text) if (status === 'running') { - statusLoader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), 'Working — Enter sends steering, Esc cancels') - statusContainer.addChild(statusLoader) + runningStartedAt = now() + statusTicker = setInterval(requestRender, 1_000) + statusTicker.unref() runtime.terminal.setProgress(true) } requestRender() @@ -1072,6 +1401,9 @@ export function createTuiChat( } const dialog = new QuestionDialog( question, + pending.index + 1, + pending.request.questions.length, + pending.request.questions.length - pending.answers.length, resolved.maxQuestionOptions, palette, (selection) => { @@ -1090,8 +1422,8 @@ export function createTuiChat( pending.overlay = ui.showOverlay(dialog, { width: resolved.questionDialogWidth, maxHeight: resolved.questionDialogMaxHeight, - anchor: 'center', - margin: 1, + anchor: 'bottom-left', + margin: { bottom: 1 }, }) requestRender() } @@ -1130,7 +1462,10 @@ export function createTuiChat( const shutdown = (exitProcess: boolean): Promise => { shuttingDown ??= (async () => { disposed = true + contextResolution = undefined clearStatus() + modelOverlay?.hide() + modelOverlay = undefined for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) commandControllers.clear() if (activeQuestion !== undefined) { @@ -1184,7 +1519,7 @@ export function createTuiChat( chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', - 'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning', + 'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning', 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit', '', ...commandLines, @@ -1213,6 +1548,15 @@ export function createTuiChat( description: 'Show keyboard shortcuts and commands', handler: () => { showHelp(); return { kind: 'success' } }, }) + commandCtx.commands.register({ + name: 'model', + description: 'Show or switch this session\'s model', + input: { hint: '[[provider/]model]' }, + handler: ({ rawInput }) => { + queueModelCommand(rawInput) + return { kind: 'success' } + }, + }) commandCtx.commands.register({ name: 'clear', description: 'Clear the transcript view (session history is unchanged)', @@ -1288,7 +1632,7 @@ export function createTuiChat( } const removeInputListener = ui.addInputListener((data) => { - if (activeQuestion !== undefined) return undefined + if (activeQuestion !== undefined || modelOverlay !== undefined) return undefined if (matchesKey(data, Key.ctrl('o'))) { toggleTools() return { consume: true } @@ -1358,6 +1702,7 @@ export function createTuiChat( disposeStatus() disposeError() disposeAgent() + disposeTargetListeners() } rebuildTranscript(true) diff --git a/packages/ui/tui/src/invariant.ts b/packages/ui/tui/src/invariant.ts new file mode 100644 index 0000000000..f8072f3968 --- /dev/null +++ b/packages/ui/tui/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tui' + +/** Cordis companion plugin name. */ +export const name = 'tui-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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 c24577b7fd..2402e85eda 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -1,9 +1,10 @@ import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createTuiChat, type Config } from '../src/index.ts' @@ -22,6 +23,16 @@ export interface TuiHarnessOptions { configureContext?: (ctx: Context) => Promise beforeMount?: (session: Session) => void cwd?: string | null + agentOptions?: AgentOptions + contextWindow?: number + contextTokens?: number + now?: () => number + catalog?: { + providers: LlmProviderInfo[] + models: LlmModelInfo[] + listModels?: (provider: string) => Promise + resolveModelContext?: (provider: string, model: string) => Promise + } } export interface TuiHarness void> { @@ -50,6 +61,31 @@ export async function createTuiTestHarness ({ ...provider })) + }, + listModels(provider: string) { + return catalog.listModels?.(provider) + ?? Promise.resolve(catalog.models.filter(model => 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', { + measure() { + return { totalTokens: options.contextTokens ?? 0 } + }, + } as never) if (options.configureContext === undefined) { const tools = options.tools ?? {} ctx.provide('tools', { @@ -60,18 +96,24 @@ export async function createTuiTestHarness 0) }) return { ctx, session, agent, terminal, exit, controller } } @@ -122,11 +164,10 @@ export function appendAssistant( session: Session, content: ContentBlock[], usage?: { inputTokens: number; outputTokens: number }, - position: { turn: number; step: number } = { turn: 1, step: 0 }, + position: { turn: number; step: number } = { turn: 1, step: 1 }, ): void { session.append('assistant/message', { - turn: position.turn, - step: position.step, + ...position, provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, content, ...usage === undefined ? {} : { usage }, diff --git a/packages/ui/tui/tests/plugin-shape.spec.ts b/packages/ui/tui/tests/plugin-shape.spec.ts index a5312156ed..149035099e 100644 --- a/packages/ui/tui/tests/plugin-shape.spec.ts +++ b/packages/ui/tui/tests/plugin-shape.spec.ts @@ -12,7 +12,15 @@ describe('dsh-tui plugin export shape', () => { const unwrapped = loader.unwrapExports(tui) as Record expect(unwrapped).toBe(tui) expect(unwrapped.name).toBe('ui-tui') - expect(unwrapped.inject).toEqual(['agents', 'commands', 'userInteraction', 'tools']) + expect(unwrapped.inject).toEqual([ + 'agents', + 'commands', + 'userInteraction', + 'tools', + 'llm', + 'systemPrompt', + 'tokenMeter', + ]) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index dd563c0614..b2c5547b3f 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -33,11 +33,12 @@ buffer 9| "▌ /workspace/project " style 0-0 fg=green style 2-19 dim -10| "▌ packages/ui/tui 100% " +10| "▌ … +4 lines (Ctrl+O to expand) " style 0-0 fg=green -11| "▌ … 4 more lines (Ctrl+O to expand) " + style 2-30 dim +11| "▌ [exit 0] " style 0-0 fg=green - style 2-34 dim + style 2-9 dim 12| "▌ " style 0-0 fg=green 13| @@ -53,12 +54,12 @@ buffer 17| "▌ - old line " style 0-0 fg=green style 2-11 fg=red -18| "▌ - keep " +18| "▌ … +5 lines (Ctrl+O to expand) " style 0-0 fg=green - style 2-7 fg=red -19| "▌ … 5 more lines (Ctrl+O to expand) " + style 2-30 dim +19| "▌ + expect(screen).toMatchSnapshot() " style 0-0 fg=green - style 2-34 dim + style 2-35 fg=green 20| "▌ " style 0-0 fg=green 21| @@ -102,6 +103,6 @@ buffer style 1-1 inverse 39| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -40| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +40| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 67-99 dim + style 42-99 dim diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 147d7fcdb1..0f93629223 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -122,6 +122,6 @@ buffer style 1-1 inverse 48| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -49| "/workspace/project ↑0 ↓0 idle reasoning:on tools:expanded" +49| "/workspace/project ↑0 ↓0 0% context tools:expanded deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 66-99 dim + style 41-99 dim diff --git a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt index 30deac56c6..f52c197d51 100644 --- a/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/code-mode-pending.expected.txt @@ -46,7 +46,7 @@ buffer style 1-1 inverse 16| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 18-35| diff --git a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt index 4b6ccdee48..3b84636ca8 100644 --- a/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt +++ b/packages/ui/tui/tests/snapshots/conversation-streaming.expected.txt @@ -1,5 +1,5 @@ terminal 96x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive +lifecycle started=1 stopped=0 progress=active title "DSH snapshot" cursor hidden column=1 viewportRow=17 bufferRow=17 viewport @@ -41,12 +41,12 @@ viewport 15| " Streaming visible state… " style 11-23 bold 16| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim + style 0-95 fg=bright-blue 17| " " style 1-1 inverse 18| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" - style 0-24 dim - style 63-95 dim + style 0-95 fg=bright-blue +19| "◒ Working · 0s esc interrupt" + style 0-13 fg=bright-blue + style 83-95 dim 20-35| diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt index 81d59edfec..01022fee8c 100644 --- a/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/cordis-tools-pending.expected.txt @@ -53,7 +53,7 @@ buffer style 1-1 inverse 19| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 21-35| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index 5006083358..4aff2c9065 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=29 bufferRow=29 +cursor visible column=0 viewportRow=30 bufferRow=30 buffer 0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" style 0-91 fg=bright-blue @@ -25,7 +25,7 @@ buffer style 1-18 fg=bright-blue bold 7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " style 1-61 fg=bright-black -8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning " +8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black @@ -38,28 +38,30 @@ buffer style 1-47 fg=bright-black 14| " /help — Show keyboard shortcuts and commands " style 1-44 fg=bright-black -15| " /reasoning — Toggle reasoning blocks " +15| " /model [[provider/]model] — Show or switch this session's model " + style 1-63 fg=bright-black +16| " /reasoning — Toggle reasoning blocks " style 1-36 fg=bright-black -16| " /redraw — Invalidate components and redraw the terminal " +17| " /redraw — Invalidate components and redraw the terminal " style 1-55 fg=bright-black -17| " /tools — Expand or collapse all tool cards " +18| " /tools — Expand or collapse all tool cards " style 1-42 fg=bright-black -18| -19| " provider stream failed after partial output " +19| +20| " provider stream failed after partial output " style 1-43 fg=red -20| -21| " The previous process ended during this turn. " +21| +22| " The previous process ended during this turn. " style 1-44 fg=yellow -22| -23| " Unknown command: /unknown-advanced-command " +23| +24| " Unknown command: /unknown-advanced-command " style 1-42 fg=yellow -24| "────────────────────────────────────────────────────────────────────────────────────────────" +25| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -25| " " +26| " " style 1-1 inverse -26| "────────────────────────────────────────────────────────────────────────────────────────────" +27| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 59-91 dim -28-31| + style 34-91 dim +29-31| diff --git a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt index 02395a1ff0..31af6ce523 100644 --- a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt +++ b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.expected.txt @@ -33,8 +33,9 @@ buffer style 0-0 fg=yellow 10| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), " style 0-0 fg=yellow -11| "▌ () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }), " +11| "▌ … +1 lines (Ctrl+O to expand) " style 0-0 fg=yellow + style 2-30 dim 12| "▌ ]) " style 0-0 fg=yellow 13| "▌ phase('Verify') " @@ -49,7 +50,7 @@ buffer style 1-1 inverse 18| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +19| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 20-35| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index 4a99d7e9e7..a01a0cb413 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=25 bufferRow=25 +cursor hidden column=1 viewportRow=26 bufferRow=26 buffer 0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" style 0-91 fg=bright-blue @@ -25,7 +25,7 @@ buffer style 1-18 fg=bright-blue bold 7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " style 1-61 fg=bright-black -8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning " +8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning " style 1-75 fg=bright-black 9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " style 1-73 fg=bright-black @@ -38,28 +38,30 @@ buffer style 1-47 fg=bright-black 14| " /help — Show keyboard shortcuts and commands " style 1-44 fg=bright-black -15| " /reasoning — Toggle reasoning blocks " +15| " /model [[provider/]model] — Show or switch this session's model " + style 1-63 fg=bright-black +16| " /reasoning — Toggle reasoning blocks " style 1-36 fg=bright-black -16| " /redraw — Invalidate components and redraw the terminal " +17| " /redraw — Invalidate components and redraw the terminal " style 1-55 fg=bright-black -17| " /tools — Expand or collapse all tool cards " +18| " /tools — Expand or collapse all tool cards " style 1-42 fg=bright-black -18| -19| " provider stream failed after partial output " +19| +20| " provider stream failed after partial output " style 1-43 fg=red -20| -21| " The previous process ended during this turn. " +21| +22| " The previous process ended during this turn. " style 1-44 fg=yellow -22| -23| " Unknown command: /unknown-advanced-command " +23| +24| " Unknown command: /unknown-advanced-command " style 1-42 fg=yellow -24| "────────────────────────────────────────────────────────────────────────────────────────────" +25| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -25| " " +26| " " style 1-1 inverse -26| "────────────────────────────────────────────────────────────────────────────────────────────" +27| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 59-91 dim -28-31| + style 34-91 dim +29-31| diff --git a/packages/ui/tui/tests/snapshots/model-selector.expected.txt b/packages/ui/tui/tests/snapshots/model-selector.expected.txt new file mode 100644 index 0000000000..4bd9dcbbbc --- /dev/null +++ b/packages/ui/tui/tests/snapshots/model-selector.expected.txt @@ -0,0 +1,52 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=31 bufferRow=31 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-91 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 91-91 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 91-91 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 91-91 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-91 fg=bright-blue +5| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +6| " " + style 1-1 inverse +7| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +8| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-24 dim + style 34-91 dim +9-12| +13| " ╭ Select model ────────────────────────────────────────────────────────╮ " + style 10-81 fg=bright-blue +14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ " + style 10-10 fg=bright-blue + style 12-72 fg=bright-blue inverse + style 81-81 fg=bright-blue +15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ " + style 10-10 fg=bright-blue + style 38-60 fg=bright-black + style 81-81 fg=bright-blue +16| " │ │ " + style 10-10 fg=bright-blue + style 81-81 fg=bright-blue +17| " │ ↑/↓ navigate • Enter select • Esc cancel │ " + style 10-10 fg=bright-blue + style 12-51 dim + style 81-81 fg=bright-blue +18| " ╰──────────────────────────────────────────────────────────────────────╯ " + style 10-81 fg=bright-blue +19-31| diff --git a/packages/ui/tui/tests/snapshots/model-switching.expected.txt b/packages/ui/tui/tests/snapshots/model-switching.expected.txt new file mode 100644 index 0000000000..7027c435db --- /dev/null +++ b/packages/ui/tui/tests/snapshots/model-switching.expected.txt @@ -0,0 +1,35 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=8 bufferRow=8 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-91 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 91-91 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 91-91 fg=bright-blue +3| "│ deepseek-v4-pro • main-session │" + style 0-0 fg=bright-blue + style 2-33 dim + style 91-91 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-91 fg=bright-blue +5| +6| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. " + style 1-64 fg=bright-black +7| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +8| " " + style 1-1 inverse +9| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +10| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-pro(reasoning:on)" + style 0-24 dim + style 36-91 dim +11-31| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index a2dc6676e2..44bbecdd2a 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -1,7 +1,7 @@ terminal 56x20 buffer=normal length=20 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=56 viewportRow=13 bufferRow=13 +cursor hidden column=56 viewportRow=17 bufferRow=17 viewport 0| "╭──────────────────────────────────────────────────────╮" style 0-55 fg=bright-blue @@ -18,52 +18,30 @@ viewport style 0-0 fg=bright-blue style 2-35 dim style 55-55 fg=bright-blue -4| "╰───╭ Coverage ────────────────────────────────────╮───╯" +4| "╰──────────────────────────────────────────────────────╯" style 0-55 fg=bright-blue -5| "────│ Which advanced TUI states belong in the │────" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-50 bold - style 51-51 fg=bright-blue bold - style 52-55 dim -6| " │ required matrix? │ " - style 1-1 inverse - style 4-4 fg=bright-blue - style 6-21 bold - style 51-51 fg=bright-blue -7| "────│ │────" - style 0-3 dim - style 4-4 fg=bright-blue - style 51-51 fg=bright-blue - style 52-55 dim -8| "/wor│ › [ ] Code Mode — run_code programs and capt │:com" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-6 fg=bright-blue inverse - style 7-20 inverse - style 21-49 fg=bright-black inverse - style 51-51 fg=bright-blue - style 52-55 dim -9| " │ [ ] Workflows — phases and parallel agents │ " - style 4-4 fg=bright-blue - style 21-49 fg=bright-black - style 51-51 fg=bright-blue -10| " │ [ ] Cordis tools — inspect, mount, and unm │ " - style 4-4 fg=bright-blue - style 24-49 fg=bright-black - style 51-51 fg=bright-blue -11| " │ 1/4 │ " - style 4-4 fg=bright-blue - style 6-8 dim - style 51-51 fg=bright-blue -12| " │ ↑↓ navigate • Space toggle • Enter submit • │ " - style 4-4 fg=bright-blue - style 6-49 dim - style 51-51 fg=bright-blue -13| " │ Select at least one option, or press C for a │ " - style 4-4 fg=bright-blue - style 6-49 fg=red - style 51-51 fg=bright-blue -14| " ╰──────────────────────────────────────────────╯ " - style 4-51 fg=bright-blue -15-19| +5| " " +6| " Question 1/3 (3 unanswered) · Coverage " + style 2-39 fg=bright-black +7| " Which advanced TUI states belong in the required " +8| " matrix? " +9| " " +10| " › 1. [ ] Code Mode run_code programs and capture " + style 2-19 fg=bright-blue bold + style 25-53 fg=bright-black +11| " 2. [ ] Workflows phases and parallel agents " + style 25-50 fg=bright-black +12| " 3. [ ] Cordis tools inspect, mount, and unmount " + style 25-51 fg=bright-black +13| " 1/4 " + style 2-4 dim +14| " Tab custom answer • ↑/↓ navigate • Space toggle • " + style 2-55 dim +15| " Enter submit • Esc interrupt " + style 2-29 dim +16| " Select at least one option, or press Tab for a " + style 2-55 fg=red +17| " custom answer. " + style 2-15 fg=red +18| " " +19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 95dc4f2496..a761c2a3ab 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -20,48 +20,28 @@ viewport style 55-55 fg=bright-blue 4| "╰──────────────────────────────────────────────────────╯" style 0-55 fg=bright-blue -5| "────╭ Coverage ────────────────────────────────────╮────" - style 0-3 dim - style 4-51 fg=bright-blue - style 52-55 dim -6| " │ Which advanced TUI states belong in the │ " +5| "────────────────────────────────────────────────────────" + style 0-55 dim +6| " " style 1-1 inverse - style 4-4 fg=bright-blue - style 6-50 bold - style 51-51 fg=bright-blue bold -7| "────│ required matrix? │────" - style 0-3 dim - style 4-4 fg=bright-blue - style 6-21 bold - style 51-51 fg=bright-blue - style 52-55 dim -8| "/wor│ │:com" - style 0-3 dim - style 4-4 fg=bright-blue - style 51-51 fg=bright-blue - style 52-55 dim -9| " │ › [ ] Code Mode — run_code programs and capt │ " - style 4-4 fg=bright-blue - style 6-6 fg=bright-blue inverse - style 7-20 inverse - style 21-49 fg=bright-black inverse - style 51-51 fg=bright-blue -10| " │ [ ] Workflows — phases and parallel agents │ " - style 4-4 fg=bright-blue - style 21-49 fg=bright-black - style 51-51 fg=bright-blue -11| " │ [ ] Cordis tools — inspect, mount, and unm │ " - style 4-4 fg=bright-blue - style 24-49 fg=bright-black - style 51-51 fg=bright-blue -12| " │ 1/4 │ " - style 4-4 fg=bright-blue - style 6-8 dim - style 51-51 fg=bright-blue -13| " │ ↑↓ navigate • Space toggle • Enter submit • │ " - style 4-4 fg=bright-blue - style 6-49 dim - style 51-51 fg=bright-blue -14| " ╰──────────────────────────────────────────────╯ " - style 4-51 fg=bright-blue -15-19| +7| " " +8| " Question 1/3 (3 unanswered) · Coverage " + style 2-39 fg=bright-black +9| " Which advanced TUI states belong in the required " +10| " matrix? " +11| " " +12| " › 1. [ ] Code Mode run_code programs and capture " + style 2-19 fg=bright-blue bold + style 25-53 fg=bright-black +13| " 2. [ ] Workflows phases and parallel agents " + style 25-50 fg=bright-black +14| " 3. [ ] Cordis tools inspect, mount, and unmount " + style 25-51 fg=bright-black +15| " 1/4 " + style 2-4 dim +16| " Tab custom answer • ↑/↓ navigate • Space toggle • " + style 2-55 dim +17| " Enter submit • Esc interrupt " + style 2-29 dim +18| " " +19| diff --git a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt index f257b1e9f6..822ac7b266 100644 --- a/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-cancelled.expected.txt @@ -42,7 +42,7 @@ buffer style 1-1 inverse 16| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 18-35| diff --git a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt index b1cf16d42a..c3be43370f 100644 --- a/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-exhausted.expected.txt @@ -39,7 +39,7 @@ buffer style 1-1 inverse 14| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 16-35| diff --git a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt index 7d692e4365..e23e6aa6c7 100644 --- a/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-recovered.expected.txt @@ -43,7 +43,7 @@ buffer style 1-1 inverse 17| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +18| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 19-35| diff --git a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt index 5cd27db26b..07f73cff2a 100644 --- a/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt +++ b/packages/ui/tui/tests/snapshots/retry-scheduled.expected.txt @@ -39,7 +39,7 @@ buffer style 1-1 inverse 14| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 63-95 dim + style 38-95 dim 16-35| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt index 7c63f3491e..28794f6d7f 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.expected.txt @@ -35,7 +35,6 @@ buffer style 1-1 inverse 12| "────────────────────────────────────────────" style 0-43 dim -13| "/workspace/project ↑0 ↓0 idle reasoning:o" - style 0-24 dim - style 27-43 dim +13| " 0% context deepseek-v4-flash(reasoning:on)" + style 1-43 dim 14-17| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt index c5448befc8..d2e86239f2 100644 --- a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.expected.txt @@ -31,7 +31,7 @@ buffer style 1-1 inverse 10| "────────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-103 dim -11| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +11| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 71-103 dim + style 46-103 dim 12-29| diff --git a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt index 5c2bbacb4e..bebdb78357 100644 --- a/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt +++ b/packages/ui/tui/tests/snapshots/surface-before-compaction.expected.txt @@ -45,8 +45,9 @@ buffer style 2-19 dim 15| "▌ packages/ui/tui 100% " style 0-0 fg=green -16| "▌ 4016 tests passed " +16| "▌ … +1 lines (Ctrl+O to expand) " style 0-0 fg=green + style 2-30 dim 17| "▌ 1 test skipped " style 0-0 fg=green 18| "▌ coverage complete " @@ -62,6 +63,6 @@ buffer style 1-1 inverse 23| "────────────────────────────────────────────────────────────────────────────────" style 0-79 dim -24| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" - style 0-24 dim - style 47-79 dim +24| "/workspace/pro ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" + style 0-13 dim + style 22-79 dim diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index 1d82ec3c45..4d541b4687 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -49,36 +49,19 @@ buffer 19| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green style 2-65 fg=bright-black -20| "▌ /unsafe/\\x1b╭ Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m ─────────╮ " +20| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green - style 2-13 dim - style 14-85 fg=bright-blue -21| "▌ Unsafe outpu│ Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │ " + style 2-54 dim +21| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-0 fg=green - style 14-14 fg=bright-blue - style 16-76 bold - style 85-85 fg=bright-blue -22| "▌ [signal SIG\\│ │ " +22| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " style 0-0 fg=green - style 2-13 fg=red - style 14-14 fg=bright-blue - style 85-85 fg=bright-blue -23| "▌ │ › ● Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m — Un │ " + style 2-58 fg=red +23| "▌ " style 0-0 fg=green - style 14-14 fg=bright-blue - style 16-16 fg=bright-blue inverse - style 17-17 inverse - style 18-18 fg=bright-blue inverse - style 19-78 inverse - style 79-83 fg=bright-black inverse - style 85-85 fg=bright-blue -24| " │ ↑↓ navigate • Enter select • C custom • Esc cancel │ " - style 14-14 fg=bright-blue - style 16-65 dim - style 85-85 fg=bright-blue -25| " Context · uns╰──────────────────────────────────────────────────────────────────────╯ " - style 1-13 dim - style 14-85 fg=bright-blue +24| +25| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-62 dim 26| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 1-60 fg=bright-black 27| @@ -88,19 +71,17 @@ buffer 30| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 1-63 fg=red 31| -32| " Unsafe live error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " - style 1-63 fg=red -33| -34| "Plan" - style 0-3 fg=bright-blue bold -35| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" - style 2-2 fg=yellow -36| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -37| " " - style 1-1 inverse -38| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -39| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" +32| " " +33| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 2-90 fg=bright-black +34| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +35| " " +36| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " + style 2-65 fg=bright-blue bold + style 67-97 fg=bright-black +37| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt " + style 2-64 dim +38| " " +39| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)" style 0-24 dim - style 67-99 dim + style 42-99 dim diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index b8afc452cb..eb901a3728 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -3,6 +3,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import type { Context } from 'cordis' +import { agentEvents } from '@deepseek-ai/dsh-agent' import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-llm-retry' import type { Session } from '@deepseek-ai/dsh-session' @@ -40,6 +41,8 @@ const CHECKPOINTS = [ 'surface-before-compaction', 'surface-after-compaction-narrow', 'surface-after-compaction-wide', + 'model-selector', + 'model-switching', 'errors-and-help', 'disposed-terminal', ] as const @@ -98,7 +101,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 }) } @@ -119,7 +122,7 @@ function appendToolCalls(session: Session, calls: readonly ToolCallFixture[]): v for (const call of calls) { session.append('tool/call', { turn: 1, - step: 0, + step: 1, callId: CallId(call.id), name: call.name, arguments: JSON.stringify(call.arguments), @@ -135,7 +138,7 @@ function appendToolResult( ): void { session.append('tool/result', { turn: 1, - step: 0, + step: 1, callId: CallId(id), content, isError: options.isError ?? false, @@ -201,25 +204,27 @@ describe('TUI terminal-state snapshots', () => { it('pins an in-flight reasoning and Markdown stream', async () => { const harness = await setupSnapshot() await renderAfter(harness, () => { + harness.agent.status = 'running' + harness.ctx.emit('agent/status', harness.agent, 'running') 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**…' }, }) }) @@ -426,9 +431,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 }) @@ -450,7 +456,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 }) @@ -463,25 +469,29 @@ describe('TUI terminal-state snapshots', () => { const harness = await setupSnapshot({ config: { maxQuestionOptions: 3, - questionDialogWidth: 48, + questionDialogWidth: 200, questionDialogMaxHeight: 16, }, }, { columns: 56, rows: 20 }) const controller = new AbortController() const beforeQuestion = harness.terminal.frames const answer = harness.ctx.userInteraction.ask({ - questions: [{ - id: 'coverage', - header: 'Coverage', - question: 'Which advanced TUI states belong in the required matrix?', - multiSelect: true, - options: [ - { label: 'Code Mode', description: 'run_code programs and captured output' }, - { label: 'Workflows', description: 'phases and parallel agents' }, - { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, - { label: 'Compaction', description: 'surface replacement and reflow' }, - ], - }], + questions: [ + { + id: 'coverage', + header: 'Coverage', + question: 'Which advanced TUI states belong in the required matrix?', + multiSelect: true, + options: [ + { label: 'Code Mode', description: 'run_code programs and captured output' }, + { label: 'Workflows', description: 'phases and parallel agents' }, + { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, + { label: 'Compaction', description: 'surface replacement and reflow' }, + ], + }, + { id: 'priority', question: 'Which state should be implemented first?' }, + { id: 'notes', question: 'Any additional constraints?' }, + ], signal: controller.signal, }) const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) @@ -508,14 +518,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, @@ -551,13 +561,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' }, }) }) @@ -569,6 +581,21 @@ describe('TUI terminal-state snapshots', () => { await harness.ctx.fiber.dispose() await harness.terminal.dispose() }) + + it('pins the model selector and selection notice', async () => { + const harness = await setupSnapshot({}, { columns: 92, rows: 32 }) + await renderAfter(harness, () => { + harness.terminal.send('/model') + harness.terminal.send('\r') + }) + await checkpoint('model-selector', harness.terminal, { includeScrollback: true }) + await renderAfter(harness, () => { + harness.terminal.send('\x1b[B') + harness.terminal.send('\r') + }) + await checkpoint('model-switching', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) }) afterAll(async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index aa2ff5510e..76aab4f045 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3,7 +3,8 @@ 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, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' @@ -112,14 +113,25 @@ async function dispose(setupResult: Awaited>): Promise< await disposeTuiTestHarness(setupResult) } +function provideTokenMeter(ctx: Context): void { + ctx.provide('tokenMeter', { + measure() { + return { totalTokens: 0 } + }, + } as never) +} + describe('TUI config', () => { it('defaults every direct-call TUI option', () => { expect(resolveTuiConfig(undefined)).toEqual({ showReasoning: true, - maxToolOutputLines: 12, + maxToolOutputLines: 6, maxQuestionOptions: 8, - questionDialogWidth: 72, + maxModelOptions: 8, + questionDialogWidth: 200, questionDialogMaxHeight: 20, + modelDialogWidth: 72, + modelDialogMaxHeight: 20, showHardwareCursor: false, color: true, title: 'DeepSeek Harness', @@ -128,8 +140,11 @@ describe('TUI config', () => { showReasoning: false, maxToolOutputLines: 2, maxQuestionOptions: 3, + maxModelOptions: 4, questionDialogWidth: 60, questionDialogMaxHeight: 14, + modelDialogWidth: 64, + modelDialogMaxHeight: 16, showHardwareCursor: true, color: false, title: 'DSH', @@ -137,8 +152,11 @@ describe('TUI config', () => { showReasoning: false, maxToolOutputLines: 2, maxQuestionOptions: 3, + maxModelOptions: 4, questionDialogWidth: 60, questionDialogMaxHeight: 14, + modelDialogWidth: 64, + modelDialogMaxHeight: 16, showHardwareCursor: true, color: false, title: 'DSH', @@ -148,7 +166,11 @@ describe('TUI config', () => { describe('pi-tui chat lifecycle and transcript', () => { it('renders its header, footer, replay, streaming answer, todos, and status', async () => { + let now = 0 const result = await setup({ + contextWindow: 100, + contextTokens: 42, + now: () => now, beforeMount(session) { appendUser(session, 'restored prompt') appendAssistant(session, [ @@ -174,9 +196,19 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('restored answer') expect(result.terminal.output).toContain('write tests') expect(result.terminal.output).toContain('↑1.3k ↓42') + expect(result.terminal.output).toContain('42% context tools:compact deepseek-v4-flash(reasoning:on)') + result.terminal.resize(52) + await tick() + expect(result.terminal.output).toContain('42% context deepseek-v4-flash(reasoning:on)') + result.terminal.resize(65) + await tick() + expect(result.terminal.output).toContain('↑1.3k ↓42 42% context deepseek-v4-flash(reasoning:on)') + result.terminal.resize(88) + await tick() result.agent.status = 'running' - result.ctx.emit('agent/status', result.agent, 'running') + agentEvents(result.ctx, result.agent).emit('agent/status', 'running') + now = 8_000 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' }) @@ -184,62 +216,65 @@ 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() @@ -250,33 +285,35 @@ describe('pi-tui chat lifecycle and transcript', () => { result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 }, - { turn: 2, step: 0 }, + { turn: 3, step: 1 }, ) await tick() - expect(result.terminal.output).toContain('Working') + expect(result.terminal.output).toContain('◒ Working · 8s') + expect(result.terminal.output).toContain('esc interrupt') expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('user context') expect(result.terminal.output).toContain('Prompt blocked') expect(result.terminal.output).toContain('Turn cancelled') expect(result.terminal.output).toContain('final live answer') - expect(result.terminal.output).toContain('↑1.8k ↓50') expect(result.terminal.progress).toContain(true) 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.output).toContain('↑1.8k ↓50') + expect(result.terminal.output).toContain('deepseek-v4-flash(reasoning:off)') expect(result.terminal.progress.at(-1)).toBe(false) await dispose(result) expect(result.terminal.stopped).toBe(1) @@ -393,8 +430,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' }, }) }, @@ -446,6 +483,7 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\r') result.agent.status = 'running' + result.ctx.emit('agent/status', result.agent, 'running') result.terminal.send('steer it') result.terminal.send('\r') expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]]) @@ -500,6 +538,189 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(disposedAgent) }) + 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: [ + { provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' }, + { provider: 'alpha', id: 'shared', name: 'Alpha Shared' }, + { 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 }), + }, + }) + + for (const command of ['/model too many model arguments', '/model missing', '/model shared', '/model alpha/a1', '/model alpha a1']) { + result.terminal.send(command) + result.terminal.send('\r') + await tick() + } + expect(result.terminal.output).toContain('Usage: /model') + expect(result.terminal.output).toContain('Unknown model: missing') + expect(result.terminal.output).toContain('advertised by multiple providers') + expect(result.terminal.output).toContain('already alpha/a1') + + result.agent.status = 'running' + result.terminal.send('/model') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Select model') + expect(result.terminal.output).toContain('alpha/a1') + expect(result.terminal.output).toContain('Alpha One — Fast — current') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + result.terminal.send('\r') + await tick() + 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') + await tick() + result.terminal.send('\x1b') + await tick() + expect(result.agent.cancelled).not.toContain('cancelled from terminal') + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + await tick() + 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' }) + const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 } + const request = await agentEvents(result.ctx, result.agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + ) + expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) + await dispose(result) + }) + + it('restores the logged model, keeps an unlisted current model visible, and reports catalog failures', async () => { + const resumed = await setup({ + agentOptions: { provider: 'alpha', model: 'configured' }, + catalog: { providers: [{ id: 'beta', name: 'Beta' }], models: [] }, + beforeMount(session) { + session.append('request/header', { + header: { config: { provider: 'beta', model: 'private' } }, + reason: 'initial', + }) + }, + }) + resumed.terminal.send('/model') + resumed.terminal.send('\r') + await tick() + expect(resumed.terminal.output).toContain('Select model') + expect(resumed.terminal.output).toContain('beta/private') + expect(resumed.terminal.output).toContain('private — current') + await dispose(resumed) + + const unset = await setup({ + agentOptions: {}, + catalog: { + providers: [{ id: 'alpha', name: 'Alpha' }], + models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }], + resolveModelContext: () => Promise.resolve(undefined), + }, + }) + unset.terminal.send('/model') + unset.terminal.send('\r') + await tick() + 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: [] } }) + empty.terminal.send('/model') + empty.terminal.send('\r') + await tick() + expect(empty.terminal.output).toContain('Current model: unset') + expect(empty.terminal.output).toContain('No models are advertised') + const assembly = await empty.ctx.systemPrompt.assemble(assembleContextFor(empty.agent)) + expect(assembly.variables).toEqual({}) + const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' } + await expect(agentEvents(empty.ctx, empty.agent).waterfall( + 'agent/request', 1, 0, seed, () => Promise.resolve(seed), + )).resolves.toBe(seed) + await dispose(empty) + + const failed = await setup({ + catalog: { + 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) + }) + + it('does not render a model catalog that resolves after TUI disposal', async () => { + const deferred = Promise.withResolvers() + const result = await setup({ + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [], + listModels: () => deferred.promise, + }, + }) + result.terminal.send('/model') + result.terminal.send('\r') + await result.controller.dispose() + deferred.resolve([]) + await tick() + expect(result.terminal.output).not.toContain('Available models') + await result.ctx.fiber.dispose() + + const rejected = Promise.withResolvers() + const rejectedResult = await setup({ + catalog: { + providers: [{ id: 'deepseek', name: 'DeepSeek' }], + models: [], + listModels: () => rejected.promise, + }, + }) + rejectedResult.terminal.send('/model') + rejectedResult.terminal.send('\r') + await rejectedResult.controller.dispose() + rejected.reject(new Error('late catalog failure')) + 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 () => { const result = await setup() const handler = vi.fn(({ rawInput }: CommandInvocation) => ({ @@ -613,22 +834,30 @@ 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' } }) + 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' } }) + events.session.append('turn/start', { turn: 7, trigger: { kind: 'message', source: { kind: 'user' } } }) events.session.append('turn/end', { - turn: 9, + turn: 7, reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } }, }) - events.ctx.emit('agent/disposed', events.agent) + agentEvents(events.ctx, events.agent).emit('agent/disposed') await tick() expect(events.terminal.output).toContain('live failure') expect(events.terminal.output).toContain('durable failure') @@ -701,7 +930,7 @@ describe('tool cards and surface replay', () => { } it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => { - const result = await setup({ tools, config: { maxToolOutputLines: 1 } }) + const result = await setup({ tools, config: { maxToolOutputLines: 4 } }) const calls = [ ['c1', 'bash', '{"command":"printf hello"}'], ['c2', 'signal', '{}'], @@ -722,7 +951,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') @@ -732,23 +961,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' }] }, @@ -757,20 +986,25 @@ 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() const output = result.terminal.output expect(output).toContain('Run command') expect(output).toContain('printf hello') - expect(output).toContain('more lines') + expect(output).toContain('lines (Ctrl+O to expand)') expect(output).toContain('SIGTERM') expect(output).toContain('Edit files') expect(output).toContain('Inspected') @@ -787,6 +1021,11 @@ describe('tool cards and surface replay', () => { result.terminal.send('/redraw') result.terminal.send('\r') await tick() + const collapsed = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(collapsed).toContain('Run command') + expect(collapsed).toContain('[exit 0]') + expect(collapsed).not.toContain('▌ hello') + expect(collapsed).not.toContain('world') result.terminal.send('\x0f') await tick() expect(result.terminal.output).toContain('world') @@ -799,15 +1038,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', { @@ -840,6 +1079,7 @@ describe('TUI user-interaction dialogs', () => { }) await tick() expect(result.terminal.output).toContain('Choose a mode') + expect(result.terminal.output).toContain('Question 1/1 (1 unanswered) · Mode') expect(result.terminal.output).toContain('1/2') result.terminal.send('\x1b[B') result.terminal.send('\r') @@ -859,7 +1099,7 @@ describe('TUI user-interaction dialogs', () => { questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], }) await tick() - result.terminal.send('c') + result.terminal.send('\t') result.terminal.send('my choice') result.terminal.send('\r') await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] }) @@ -933,9 +1173,11 @@ describe('TUI user-interaction dialogs', () => { ], }) await tick() + expect(result.terminal.output).toContain('Question 1/2 (2 unanswered)') result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('Second?') + expect(result.terminal.output).toContain('Question 2/2 (1 unanswered)') result.terminal.send('done') result.terminal.send('\r') await expect(batch).resolves.toEqual({ answers: [ @@ -982,6 +1224,7 @@ describe('TUI user-interaction dialogs', () => { describe('terminal mounting', () => { it('starts immediately when the configured agent already exists', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) @@ -1001,6 +1244,7 @@ describe('terminal mounting', () => { it('waits for its configured agent before starting the TUI', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) @@ -1030,6 +1274,7 @@ describe('terminal mounting', () => { it('prints a matching live startup failure and exits instead of waiting forever', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) @@ -1058,6 +1303,7 @@ describe('terminal mounting', () => { it('renders an uncoercible startup failure without escaping the display boundary', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) @@ -1079,12 +1325,15 @@ describe('terminal mounting', () => { it('rolls back providers, listeners, and terminal state when startup fails', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) 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(), @@ -1102,7 +1351,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() @@ -1112,6 +1361,7 @@ describe('terminal mounting', () => { it('throws when createTuiChat is called without the configured agent', async () => { const ctx = new Context() + provideTokenMeter(ctx) await ctx.plugin(AgentRegistry) await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 62cfef1a14..dc7d623008 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/token-meter" + }, { "path": "../../llm/llm-retry" }, @@ -37,6 +40,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..5643e412c5 --- /dev/null +++ b/packages/ui/user-approval/src/invariant.ts @@ -0,0 +1,93 @@ +/** Package-owned approval audit-stream invariants. @module @deepseek-ai/dsh-user-approval/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +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. */ +// 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() + 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'] }) +/* jscpd:ignore-end */ + +/** + * Register the approval 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/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/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-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..f4f2f2f31e --- /dev/null +++ b/packages/ui/user-interaction/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..bf29a81b4c --- /dev/null +++ b/packages/util/brand/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-brand' + +/** Cordis companion plugin name. */ +export const name = 'brand-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..dbd8e38f51 --- /dev/null +++ b/packages/util/home/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-home`. + * @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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..f1661b7f52 --- /dev/null +++ b/packages/util/paths/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-paths`. + * @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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..0365793b03 --- /dev/null +++ b/packages/util/retention/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-retention' + +/** Cordis companion plugin name. */ +export const name = 'retention-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..bb9604d6b7 --- /dev/null +++ b/packages/util/timeout/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-timeout' + +/** Cordis companion plugin name. */ +export const name = 'timeout-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..435f9ca549 --- /dev/null +++ b/packages/web/tool-web/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..053fb12200 --- /dev/null +++ b/packages/web/web-fetch-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..8b79315f7d --- /dev/null +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..060ceb78ba --- /dev/null +++ b/packages/web/web-search-exa/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..cf3e009fed --- /dev/null +++ b/packages/web/web-search-perplexity/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..2ac094b34f --- /dev/null +++ b/packages/web/web/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion 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' + +const PACKAGE_NAME = '@deepseek-ai/dsh-web' + +/** Cordis companion plugin name. */ +export const name = 'web-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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-ralph/package.json b/packages/workflow/tool-ralph/package.json index 31c368b6f4..fb12147445 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/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-system-prompt": "^0.0.1", diff --git a/packages/workflow/tool-ralph/src/invariant.ts b/packages/workflow/tool-ralph/src/invariant.ts new file mode 100644 index 0000000000..22a7d1f2ea --- /dev/null +++ b/packages/workflow/tool-ralph/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-ralph`. + * @module @deepseek-ai/dsh-tool-ralph/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ralph' + +/** Cordis companion plugin name. */ +export const name = 'tool-ralph-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this model-facing orchestration adapter owns no independent event stream; + * workflow and subagent owners validate the runs and child lifecycles it starts. + */ +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-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index e2c704eaa3..a5b44712e8 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -3,7 +3,6 @@ import { Context } from 'cordis' 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 { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -20,7 +19,6 @@ async function mountRalph(script: MockScript, config: toolRalph.Config) { const ctx = new Context() const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -58,7 +56,6 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { toolCallResponse('round-2', STRUCTURED_OUTPUT_TOOL, finalReport), ]) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/workflow/tool-ralph/tsconfig.json b/packages/workflow/tool-ralph/tsconfig.json index 294a851a5b..d69119b10b 100644 --- a/packages/workflow/tool-ralph/tsconfig.json +++ b/packages/workflow/tool-ralph/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../workflow" + }, + { + "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..5f3ebc68ce --- /dev/null +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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..6845aeebff --- /dev/null +++ b/packages/workflow/workflow-workerthread/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * 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 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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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. + * @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/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/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..f6b8b8ced2 --- /dev/null +++ b/packages/workflow/workflow/src/invariant.ts @@ -0,0 +1,136 @@ +/** Package-owned workflow lifecycle invariants. @module @deepseek-ai/dsh-workflow/invariant */ + +import type { Context } from 'cordis' +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' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +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) => { + 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 the workflow 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/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/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 dae8f9b97b..04bdda2dd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,12 @@ importers: '@deepseek-ai/dsh-llm-replay': specifier: workspace:* version: link:../packages/support/llm-replay + '@deepseek-ai/dsh-lsp': + specifier: workspace:* + version: link:../packages/lsp/lsp + '@deepseek-ai/dsh-lsp-local': + specifier: workspace:* + version: link:../packages/lsp/lsp-local '@deepseek-ai/dsh-permission': specifier: workspace:* version: link:../packages/ui/permission @@ -182,6 +188,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../packages/subagent/subagent + '@deepseek-ai/dsh-subagent-acp': + specifier: workspace:* + version: link:../packages/subagent/subagent-acp '@deepseek-ai/dsh-subagent-fork': specifier: workspace:* version: link:../packages/subagent/subagent-fork @@ -209,6 +218,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:* version: link:../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-lsp': + specifier: workspace:* + version: link:../packages/lsp/tool-lsp '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:../packages/workflow/tool-ralph @@ -246,6 +258,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 @@ -262,6 +277,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 @@ -277,6 +295,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 @@ -317,6 +338,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 @@ -356,6 +380,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) @@ -369,12 +396,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 @@ -474,6 +507,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 @@ -514,6 +550,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 @@ -560,6 +599,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 @@ -584,6 +626,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 @@ -639,6 +684,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) @@ -648,6 +696,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 @@ -664,6 +715,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 @@ -686,6 +740,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 @@ -731,6 +788,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../ui/commands + '@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 @@ -804,6 +864,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -864,6 +927,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 @@ -895,6 +961,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) @@ -925,6 +994,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../ui/commands + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -964,6 +1036,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 @@ -983,6 +1058,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 @@ -995,6 +1073,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 @@ -1010,6 +1091,9 @@ importers: '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../fs-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -1047,6 +1131,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 @@ -1090,6 +1177,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 @@ -1126,6 +1216,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1148,6 +1241,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 @@ -1178,6 +1274,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1209,6 +1308,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1240,6 +1342,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 @@ -1258,6 +1363,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 @@ -1289,6 +1397,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 @@ -1335,6 +1446,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 @@ -1359,6 +1473,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) @@ -1369,6 +1486,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 @@ -1388,6 +1508,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 @@ -1419,6 +1542,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 @@ -1450,6 +1576,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 @@ -1460,6 +1589,92 @@ importers: 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/lsp/lsp: + devDependencies: + '@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 + 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/lsp/lsp-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@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 + '@deepseek-ai/dsh-lsp': + specifier: workspace:^ + version: link:../lsp + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + 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) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + typescript-language-server: + specifier: ^5.0.0 + version: 5.3.0 + + packages/lsp/tool-lsp: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@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 + '@deepseek-ai/dsh-lsp': + specifier: workspace:^ + version: link:../lsp + '@deepseek-ai/dsh-lsp-local': + specifier: workspace:^ + version: link:../lsp-local + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:^ + version: link:../../timeout/timeout-policy + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + 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/mcp/mcp-client: dependencies: '@modelcontextprotocol/sdk': @@ -1469,6 +1684,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 @@ -1490,6 +1708,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 @@ -1506,6 +1727,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 @@ -1522,6 +1746,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../sandbox @@ -1541,6 +1768,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) @@ -1572,6 +1802,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 @@ -1606,6 +1839,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) @@ -1625,12 +1861,21 @@ 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) 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 @@ -1644,6 +1889,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 @@ -1660,6 +1908,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 @@ -1676,6 +1927,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 @@ -1695,6 +1949,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) @@ -1714,6 +1971,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 @@ -1730,6 +1990,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 @@ -1754,6 +2017,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 @@ -1773,6 +2039,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 @@ -1795,6 +2064,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 @@ -1822,6 +2094,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 @@ -1853,6 +2128,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 @@ -1996,6 +2274,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) @@ -2012,6 +2293,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 @@ -2046,6 +2330,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) @@ -2058,6 +2345,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 @@ -2075,40 +2365,20 @@ 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-goal': - specifier: workspace:^ - version: link:../../goal/goal - '@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) packages/support/llm-replay: devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2125,6 +2395,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) @@ -2137,6 +2410,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 @@ -2156,6 +2432,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 @@ -2177,6 +2456,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 @@ -2201,6 +2483,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 @@ -2313,6 +2598,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) @@ -2322,6 +2610,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-scope': specifier: workspace:^ version: link:../../core/scope @@ -2347,6 +2638,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 @@ -2378,6 +2672,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 @@ -2399,6 +2696,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 @@ -2436,6 +2736,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../commands + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2448,6 +2751,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../cordis/tool-cordis @@ -2482,6 +2788,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 @@ -2503,6 +2812,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 @@ -2512,30 +2824,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) @@ -2549,6 +2876,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 @@ -2589,6 +2919,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 @@ -2602,6 +2935,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 @@ -2618,6 +2954,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 @@ -2631,6 +2970,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 @@ -2644,6 +2986,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 @@ -2712,6 +3057,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 @@ -2745,6 +3093,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 @@ -7141,6 +7492,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + typescript-language-server@5.3.0: + resolution: {integrity: sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ==} + engines: {node: '>=20'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -7345,6 +7701,20 @@ packages: jsdom: optional: true + vscode-jsonrpc@5.0.1: + resolution: {integrity: sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A==} + engines: {node: '>=8.0.0 || >=10.0.0'} + + vscode-jsonrpc@9.0.1: + resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.18.2: + resolution: {integrity: sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + vue@3.5.39: resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==} peerDependencies: @@ -11640,6 +12010,11 @@ snapshots: transitivePeerDependencies: - supports-color + typescript-language-server@5.3.0: + dependencies: + vscode-jsonrpc: 5.0.1 + vscode-languageserver-protocol: 3.18.2 + typescript@6.0.3: {} uglify-js@3.19.3: @@ -11868,6 +12243,17 @@ snapshots: transitivePeerDependencies: - msw + vscode-jsonrpc@5.0.1: {} + + vscode-jsonrpc@9.0.1: {} + + vscode-languageserver-protocol@3.18.2: + dependencies: + vscode-jsonrpc: 9.0.1 + vscode-languageserver-types: 3.18.0 + + vscode-languageserver-types@3.18.0: {} + vue@3.5.39(typescript@6.0.3): dependencies: '@vue/compiler-dom': 3.5.39 diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index c10dd1396c..2a9cf01bdb 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -93,29 +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 packageFileExtras: Readonly> = { '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-scripts': [ @@ -131,22 +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 - // 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[] { @@ -188,6 +161,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/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 6ddfdd8f8f..5034d65cae 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, diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 200308fca9..e6aa55b629 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -35,6 +35,7 @@ export const LINK_MAP: Record = { ContinuationStop: 'core.md', GenerateOptions: 'core.md', LlmCallConfig: 'core.md', + LlmModelContext: 'core.md', LlmFailure: 'llm-streaming.md', LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', @@ -170,6 +171,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 e7efe7464f..ffe336824a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -109,9 +109,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', @@ -175,7 +183,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'agent', title: 'Agent service', mode: 'core', - consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'tui-demo', 'invariants'], + consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'tui-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/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index f570cf9d20..62d8eb6dfb 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -33,6 +33,8 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' import * as ToolGoal from '@deepseek-ai/dsh-tool-goal' +import Lsp from '@deepseek-ai/dsh-lsp' +import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' import * as ToolSkill from '@deepseek-ai/dsh-tool-skill' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -242,6 +244,20 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds.', }, + { + pkg: '@deepseek-ai/dsh-tool-lsp', + dir: 'tool-lsp', + source: 'packages/lsp/tool-lsp/src/index.ts', + requires: ['ctx.tools', 'ctx.lsp', 'ctx.systemPrompt'], + writes: ['tool/call', 'tool/result'], + async mount(ctx) { + // The tool registers from the seam alone; the schema does not depend on any provider. + await ctx.plugin(Lsp) + await ctx.plugin(ToolLsp) + }, + note: + 'The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema.', + }, { pkg: '@deepseek-ai/dsh-tool-ralph', dir: 'tool-ralph', diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts new file mode 100644 index 0000000000..787202c9d1 --- /dev/null +++ b/scripts/package-invariants.spec.ts @@ -0,0 +1,187 @@ +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, +} from './package-invariants.ts' + +const roots: string[] = [] + +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: { 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)) +` +} + +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`) + 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", + ) + return root +} + +describe('package invariant gate', () => { + it('accepts a hand-owned checking 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 +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 })) + 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 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 reporterFree = fixture({ + source: ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +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(reporterFree).map(violation => violation.message)) + .toContain('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 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.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' +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([]) + + const unexplained = ` +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: 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 new file mode 100644 index 0000000000..21bc5931ec --- /dev/null +++ b/scripts/package-invariants.ts @@ -0,0 +1,340 @@ +/** + * 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 { dirname, relative, resolve, sep } from 'node:path' +import ts from 'typescript' + +/** Required explanation marker for an intentionally empty installer. */ +const NO_RUNTIME_INVARIANT_MARKER = 'No runtime invariant:' + +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, + } + }) +} + +/** 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')) { + addViolation( + violations, + owner.sourcePath, + 'invariant companions must be hand-owned and may not carry @generated markers', + ) + } + + const sourceFile = ts.createSourceFile( + absolutePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ) + 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(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) + } + 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`, + ) + } + 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, + 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}`) + } + } + if (hasDefaultExport(sourceFile)) { + addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace') + } + 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 + declarationStatement = statement + } + } + } + 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) { + 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)) { + 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 { + 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) + }) +} + +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, + 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 61aae60297..582972f778 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -208,6 +208,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(), @@ -223,6 +224,7 @@ function ciPrimaryGates(): Gate[] { label: 'node-next types', needs: ['build'], }), + builtPackageInvariantsGate(['build']), builtBinSmokeGate(), ] } @@ -231,6 +233,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' }), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), @@ -246,6 +249,7 @@ function ciArtifactGates(): Gate[] { label: 'node-next types', needs: ['build'], }), + builtPackageInvariantsGate(['build']), builtBinSmokeGate(), ] } @@ -293,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 [] @@ -309,6 +320,8 @@ 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' }), + 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 new file mode 100644 index 0000000000..7a4f678be8 --- /dev/null +++ b/scripts/test-invariants.spec.ts @@ -0,0 +1,87 @@ +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 { + testInvariantCompanionPaths, + testInvariantCompanions, + usesManualInvariantTree, +} 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('uses one exhaustive topology to reserve 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('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('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 () => {} + }) + const fakeContext = { invariants: { register } } as unknown as Context + for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) { + const path = rawPath.replace(/^\.\.\//, '') + 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]) + } + expect(registrations).toEqual(owners) + }) + + 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 new file mode 100644 index 0000000000..a3c16f1241 --- /dev/null +++ b/scripts/test-invariants.ts @@ -0,0 +1,150 @@ +/** + * Vitest-wide invariant host. Ordinary Cordis roots receive the invariant + * 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' +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[] + readonly default?: unknown + 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 }) + +/** Manual-topology suites whose names cannot follow the focused invariant convention. */ +const MANUAL_INVARIANT_TEST_EXCEPTIONS = [ + '/packages/support/invariants/tests/service.spec.ts', + '/packages/examples/agent-spine-demo/tests/agent-core.spec.ts', +] as const + +interface InvariantHost { + readonly fibers: readonly PluginFiber[] + readonly byCallback: ReadonlyMap + readonly ready: Promise +} + +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[]) { + 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) + const callback = this.resolve(plugin) + const existing = callback === undefined ? undefined : host.byCallback.get(callback) + 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.ready) +} + +/** + * 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 + +/** + * 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() + 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 }) + 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 [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, invariantReady: Promise): PluginFiber { + const readiness = fiber.await().then(async (loaded) => { + await invariantReady + return loaded + }) + const joined = Object.create(fiber) as PluginFiber + joined.then = readiness.then.bind(readiness) + return joined +} diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f6bf07f691..8c44a21a22 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" }, @@ -205,6 +206,17 @@ { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" }, { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" }, { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowResult", "source": "packages/workflow/workflow/src/types.ts" }, - { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowRun", "source": "packages/workflow/workflow/src/types.ts" } + { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowRun", "source": "packages/workflow/workflow/src/types.ts" }, + + { "doc": "docs/core-data-structures/lsp.md", "symbol": "LspOperation", "source": "packages/lsp/lsp/src/types.ts" }, + { "doc": "docs/core-data-structures/lsp.md", "symbol": "LspPosition", "source": "packages/lsp/lsp/src/types.ts" }, + { "doc": "docs/core-data-structures/lsp.md", "symbol": "LspRange", "source": "packages/lsp/lsp/src/types.ts" }, + { "doc": "docs/core-data-structures/lsp.md", "symbol": "LspQueryRequest", "source": "packages/lsp/lsp/src/types.ts" }, + { "doc": "docs/core-data-structures/lsp.md", "symbol": "LspProviderQuery", "source": "packages/lsp/lsp/src/types.ts" }, + { "doc": "docs/core-data-structures/lsp.md", "symbol": "LspLocation", "source": "packages/lsp/lsp/src/types.ts" }, + { "doc": "docs/core-data-structures/lsp.md", "symbol": "LspHover", "source": "packages/lsp/lsp/src/types.ts" }, + { "doc": "docs/core-data-structures/lsp.md", "symbol": "LspQueryResult", "source": "packages/lsp/lsp/src/types.ts" }, + { "doc": "docs/core-data-structures/lsp.md", "symbol": "LspProvider", "source": "packages/lsp/lsp/src/types.ts" }, + { "doc": "docs/core-data-structures/lsp.md", "symbol": "LspService", "source": "packages/lsp/lsp/src/types.ts" } ] } diff --git a/scripts/verify-built-package-invariants.mjs b/scripts/verify-built-package-invariants.mjs new file mode 100644 index 0000000000..4b298946d1 --- /dev/null +++ b/scripts/verify-built-package-invariants.mjs @@ -0,0 +1,102 @@ +/** Verify every packed companion through its package self-reference under plain Node. */ + +import { spawnSync } from 'node:child_process' +import { + copyFileSync, + globSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, +} 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() +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] + +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 + } + + 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 + } + + // 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 + const target = resolve(stagedPackageDir, file.path) + mkdirSync(dirname(target), { recursive: true }) + copyFileSync(resolve(packageDir, file.path), target) + } + + 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) { + 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 }) + } +} + +if (failures.length > 0) { + 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} packed companion(s) passed plain-Node Loader checks.`) 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/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 41a13cfbb8..0f41f7be03 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -52,6 +52,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, + 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, + 'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ffcb649335..37926ce96d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,6 +34,42 @@ "@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"], + "@deepseek-ai/dsh-*/invariant": [ + "./packages/core/*/src/invariant.ts", + "./packages/prompt/*/src/invariant.ts", + "./packages/llm/*/src/invariant.ts", + "./packages/bash/*/src/invariant.ts", + "./packages/code-runtime/*/src/invariant.ts", + "./packages/fs/*/src/invariant.ts", + "./packages/skill/*/src/invariant.ts", + "./packages/compact/*/src/invariant.ts", + "./packages/context/*/src/invariant.ts", + "./packages/goal/*/src/invariant.ts", + "./packages/guard/*/src/invariant.ts", + "./packages/subagent/*/src/invariant.ts", + "./packages/tasks/*/src/invariant.ts", + "./packages/workflow/*/src/invariant.ts", + "./packages/web/*/src/invariant.ts", + "./packages/spill/*/src/invariant.ts", + "./packages/timeout/*/src/invariant.ts", + "./packages/todo/*/src/invariant.ts", + "./packages/cordis/*/src/invariant.ts", + "./packages/sandbox/*/src/invariant.ts", + "./packages/hooks/*/src/invariant.ts", + "./packages/session-persistence/*/src/invariant.ts", + "./packages/session-query/*/src/invariant.ts", + "./packages/sdk/*/src/invariant.ts", + "./packages/ui/*/src/invariant.ts", + "./packages/examples/*/src/invariant.ts", + "./packages/util/*/src/invariant.ts", + "./packages/mcp/*/src/invariant.ts", + "./packages/support/*/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 @@ -46,6 +82,7 @@ "./packages/bash/*/src", "./packages/code-runtime/*/src", "./packages/fs/*/src", + "./packages/lsp/*/src", "./packages/skill/*/src", "./packages/compact/*/src", "./packages/context/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 17d3cc52e1..d7d93cd445 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -108,6 +108,9 @@ { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, { "path": "./packages/sdk/create-sdk" }, - { "path": "./packages/sdk/telemetry" } + { "path": "./packages/sdk/telemetry" }, + { "path": "./packages/lsp/lsp" }, + { "path": "./packages/lsp/lsp-local" }, + { "path": "./packages/lsp/tool-lsp" } ] } diff --git a/tsconfig.json b/tsconfig.json index 14baf6dbf3..c6ebded4a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -122,6 +122,9 @@ { "path": "./packages/sdk/helper" }, { "path": "./packages/sdk/scripts" }, { "path": "./packages/sdk/create-sdk" }, - { "path": "./packages/sdk/telemetry" } + { "path": "./packages/sdk/telemetry" }, + { "path": "./packages/lsp/lsp" }, + { "path": "./packages/lsp/lsp-local" }, + { "path": "./packages/lsp/tool-lsp" } ] } 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 4f4baa06ba..a45bb010bc 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 6a84b13daf..c5f26b90a3 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -32,6 +32,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 78800fb4d5..142528e604 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/.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 + +# 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#L388) + +### 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 - listener or startup-check 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` — 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#L430)