From 907c6334c112a976d7183734fc47c73bf6cb6856 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 19 Aug 2026 13:33:24 +0800 Subject: [PATCH 01/48] Remove Knip from repository tooling --- .../2026-06-11-quality-gates.i18n.yaml | 4 +- .../process/2026-06-11-quality-gates.md | 2 +- .../process/2026-06-11-quality-gates.zh.md | 2 +- .../2026-06-16-pnpm-over-yarn.i18n.yaml | 4 +- .../process/2026-06-16-pnpm-over-yarn.md | 4 +- .../process/2026-06-16-pnpm-over-yarn.zh.md | 4 +- ...13-documentation-site-projection.i18n.yaml | 4 +- ...026-07-13-documentation-site-projection.md | 2 +- ...-07-13-documentation-site-projection.zh.md | 2 +- ...30-independent-ci-consumer-build.i18n.yaml | 4 +- ...026-07-30-independent-ci-consumer-build.md | 2 +- ...-07-30-independent-ci-consumer-build.zh.md | 2 +- ...-06-coverage-uncovered-locations.i18n.yaml | 4 +- ...2026-08-06-coverage-uncovered-locations.md | 2 +- ...6-08-06-coverage-uncovered-locations.zh.md | 2 +- ...026-08-10-vendor-package-rescope.i18n.yaml | 4 +- .../2026-08-10-vendor-package-rescope.md | 2 +- .../2026-08-10-vendor-package-rescope.zh.md | 2 +- ...-12-face-named-client-test-files.i18n.yaml | 4 +- ...2026-08-12-face-named-client-test-files.md | 2 +- ...6-08-12-face-named-client-test-files.zh.md | 2 +- .../process/2026-08-19-remove-knip.i18n.yaml | 6 + .../process/2026-08-19-remove-knip.md | 27 + .../process/2026-08-19-remove-knip.zh.md | 27 + ...06-20-discover-package-inventory.i18n.yaml | 4 +- .../2026-06-20-discover-package-inventory.md | 5 +- ...026-06-20-discover-package-inventory.zh.md | 5 +- .../skills/dsh-find-simplifications/SKILL.md | 4 +- AGENTS.md | 2 +- THIRD_PARTY_NOTICES.md | 1 - docs/cookbook/adding-a-package.i18n.yaml | 4 +- docs/cookbook/adding-a-package.md | 1 - docs/cookbook/adding-a-package.zh.md | 1 - knip.json | 790 ------------------ package.json | 4 +- .../cordis-host-runner/README.i18n.yaml | 4 +- .../extensions/cordis-host-runner/README.md | 2 +- .../cordis-host-runner/README.zh.md | 2 +- .../host/directory-picker-auto/src/index.ts | 5 +- pnpm-lock.yaml | 295 +------ scripts/rescope-vendor.ts | 32 - scripts/run-gates.ts | 3 - 42 files changed, 109 insertions(+), 1175 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-19-remove-knip.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-19-remove-knip.md create mode 100644 .agents/notes/implemented/process/2026-08-19-remove-knip.zh.md delete mode 100644 knip.json diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml index 3134a16824..1209855d51 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-quality-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 .agents/notes/implemented/process/2026-06-11-quality-gates.md -2026-06-11-quality-gates.md: 83cba5c867f71471d421ab935e1f53b430b01c43 -2026-06-11-quality-gates.zh.md: 1162b3836424bd47a8d35bd754a7983ac2e9bebe +2026-06-11-quality-gates.md: 6a66fc6a4ead8908bfaf3a417568a3a7eb458744 +2026-06-11-quality-gates.zh.md: 907d6d2781b69fd191daf0a91735d5cf78cd8fa1 diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.md index 83cba5c867..6a66fc6a4e 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.md @@ -18,7 +18,7 @@ Every mechanically checkable AGENTS.md promise gets a command that exits non-zer - [Oxlint](2026-07-29-oxlint-linter.md) with type-aware TypeScript rules plus the @stylistic and SonarJS compatibility plugins, enforcing the house style and file-local duplicated-logic checks; vendored code excluded. - jscpd detects cross-file clones in package production TypeScript and repository scripts; narrow source-range exceptions document deliberately parallel implementations. - Per-file 100% coverage on `packages/*/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion. -- knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations. +- publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations. [The unused-code gate removal](2026-08-19-remove-knip.md) records why static dead-code scanning is outside this suite. - lefthook pre-commit applies project-free Oxlint validation and [safe fixes with a bounded retry](2026-08-09-oxlint-only-fix-workflow.md), rejects staged whitespace, and checks the vendor manifest; pre-push runs incremental typecheck. CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths. ## Consequences diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md index 1162b38364..907d6d2781 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md @@ -18,7 +18,7 @@ Status: implemented - [Oxlint](2026-07-29-oxlint-linter.md) 配合类型感知的 TypeScript 规则以及 @stylistic 和 SonarJS 兼容插件,强制执行统一代码风格和文件内重复逻辑检查;vendor 代码排除在外。 - jscpd 检测包的生产 TypeScript 代码与仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 - `packages/*/*/src` 下按文件 100% 覆盖率(v8);不可达的防御性守卫使用 `/* v8 ignore */ ` 并注明理由,而非删除。 -- knip(死代码/依赖)、publint(包的正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 +- publint(包的正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。[移除未使用代码门禁的决策](2026-08-19-remove-knip.md)说明了静态死代码扫描为何不属于这套门禁。 - lefthook pre-commit 执行不加载项目的 Oxlint 验证,并应用带[一次有界重试](2026-08-09-oxlint-only-fix-workflow.md)的安全修复,拒绝已暂存的空白问题并检查 vendor manifest(元数据清单);pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml index dbed654d18..45728bcfbb 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.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 .agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md -2026-06-16-pnpm-over-yarn.md: 30b34c65fdea94b20dec4d627a0fca40de760fd1 -2026-06-16-pnpm-over-yarn.zh.md: 7560b748654cf79409e16b6c2b1c701b367f01d4 +2026-06-16-pnpm-over-yarn.md: f3b070430b0f92b438b25e5c7b1133f798d9e5fe +2026-06-16-pnpm-over-yarn.zh.md: 51ab6f89338f5e38517548089c59668f48dc4dad diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md index 30b34c65fd..f3b070430b 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md @@ -15,7 +15,7 @@ The switching cost is at its lowest right now. Nothing publishes from this repo Adopt **pnpm 11.7.0**, pinned via the `packageManager` field and installed through Corepack (same mechanism Yarn used): - **Workspaces** move from the `package.json` `workspaces` array + `.yarnrc.yml` to `pnpm-workspace.yaml` (`vendor/*`, `packages/*` — the same globs; `examples/*` stay non-workspace, matching the prior setup and tsdown's explicit globs). -- **Strict symlinked linker** (pnpm's default) replaces Yarn's hoisted `node-modules` linker. We deliberately add **no** `node-linker=hoisted` / `shamefully-hoist` escape hatch: pnpm's non-flat `node_modules` makes phantom dependencies (importing an undeclared transitive dep) fail loudly, which is a *feature* for a repo whose whole quality story is mechanical gates ([mechanical quality gates](2026-06-11-quality-gates.md)). The gate suite — typecheck, lint, test, build, knip — is the safety net that proves no such phantom imports exist. +- **Strict symlinked linker** (pnpm's default) replaces Yarn's hoisted `node-modules` linker. We deliberately add **no** `node-linker=hoisted` / `shamefully-hoist` escape hatch: pnpm's non-flat `node_modules` makes phantom dependencies (importing an undeclared transitive dep) fail loudly, which is a *feature* for a repo whose whole quality story is mechanical gates ([mechanical quality gates](2026-06-11-quality-gates.md)). The gate suite — typecheck, lint, test, and build — is the safety net that proves no such phantom imports exist. - **Build-script allowlist.** pnpm 10+ does not run dependency lifecycle scripts unless allowlisted. `pnpm-workspace.yaml` carries an explicit `allowBuilds` map (`esbuild`, `lefthook`, `@google/genai`, `protobufjs`) — the same supply-chain-hardening posture the repo already takes toward model/tool output, now applied to install-time code execution. `peerDependencyRules.allowedVersions.typescript: '>=5 <7'` silences benign peer-range warnings for the in-repo TypeScript. - **Constraints become package-manager-independent.** `yarn.config.cjs` (which imported `@yarnpkg/types` and used `Yarn.workspaces()` / `workspace.set()`) is replaced by `scripts/check-workspace-constraints.ts`, a plain tsx script run as `pnpm run constraints`. It enforces the identical invariants — every package `private: true`; `@deepseek-ai/dsh-*` packages declare `cordis` as both a peer- and dev-dependency with matching ranges, use the root `package.json` version, and set `type: module`; vendored packages checked for privacy only — over the same `vendor` + `packages` scope. - All `yarn …` verbs across CI, lefthook hooks, `package.json` scripts, and docs become `pnpm …` / `pnpm run …`. `yarn.lock` → `pnpm-lock.yaml` (lockfile v9). `.gitignore` swaps `.yarn/` for `.pnpm-store/`. Vendored READMEs (e.g. `vendor/cordis/README.md`) keep their upstream `yarn` examples untouched per the Vendoring Policy. @@ -40,4 +40,4 @@ Performance (measured at migration time on the dev NFS filesystem; single-digit- On a fast local disk pnpm's content-addressed store typically wins on cold/warm installs and, especially, on **disk footprint** across multiple checkouts (one global store hardlinked into every `node_modules` vs Yarn copying ~279 MB per worktree — some devs regularly keep ~10 or more worktrees for this repo). That dedup advantage did **not** show in the migration-time numbers above because the test store and `node_modules` sat on different filesystems, defeating hardlinks; on a single-filesystem dev box or CI cache it applies. The honest summary: install speed on our NFS dev filesystem is a wash within noise; the move is justified by ecosystem alignment, phantom-dependency safety, and cross-checkout disk dedup — not by a raw install-time win. -All quality gates (constraints, typecheck, lint, doc-sync, test:coverage at 100%, build, knip, publint, and built application smokes) pass on pnpm, which is the correctness proof that the linker swap introduces no phantom-dependency breakage. +All quality gates (constraints, typecheck, lint, doc-sync, test:coverage at 100%, build, publint, and built application smokes) pass on pnpm, which is the correctness proof that the linker swap introduces no phantom-dependency breakage. diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md index 7560b74865..51ab6f8933 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md @@ -15,7 +15,7 @@ Status: implemented 采用 **pnpm 11.7.0**,通过 `packageManager` 字段固定版本,经 Corepack 安装(与 Yarn 使用的机制相同): - **Workspaces** 从 `package.json` 的 `workspaces` 数组 + `.yarnrc.yml` 迁移到 `pnpm-workspace.yaml`(`vendor/*`、`packages/*`——同样的 glob;`examples/*` 保持非 workspace,与先前设置及 tsdown 的显式 glob 一致)。 -- **严格符号链接链接器**(pnpm 默认)取代 Yarn 的提升式 `node-modules` 链接器。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会使幻影依赖(引用未声明的传递依赖)明确报错,这对于一个以机械门禁为核心质量保障的仓库(见[机械质量门禁](2026-06-11-quality-gates.md))是一项*优势*。门禁套件(类型检查、lint、test、build、knip)是证明不存在此类幻影导入的安全网。 +- **严格符号链接链接器**(pnpm 默认)取代 Yarn 的提升式 `node-modules` 链接器。我们刻意**不**添加 `node-linker=hoisted` / `shamefully-hoist` 逃生口:pnpm 的非扁平 `node_modules` 会使幻影依赖(引用未声明的传递依赖)明确报错,这对于一个以机械门禁为核心质量保障的仓库(见[机械质量门禁](2026-06-11-quality-gates.md))是一项*优势*。门禁套件(类型检查、lint、test 和 build)是证明不存在此类幻影导入的安全网。 - **构建脚本白名单。** pnpm 10+ 不运行依赖的生命周期脚本,除非将其加入白名单。`pnpm-workspace.yaml` 携带一份显式的 `allowBuilds` 映射(`esbuild`、`lefthook`、`@google/genai`、`protobufjs`)——与本仓库对模型/工具输出已有的供应链加固姿态一致,现在也应用于安装时的代码执行。`peerDependencyRules.allowedVersions.typescript: '>=5 <7'` 消除仓库内 TypeScript 的良性 peer 范围警告。 - **约束变为包管理器无关。** `yarn.config.cjs`(导入 `@yarnpkg/types`,使用 `Yarn.workspaces()` / `workspace.set()`)被 `scripts/check-workspace-constraints.ts` 取代——一个纯 tsx 脚本,通过 `pnpm run constraints` 运行。它在相同的 `vendor` + `packages` 范围上强制执行完全相同的不变式:每个包 `private: true`;`@deepseek-ai/dsh-*` 包将 `cordis` 同时声明为对等依赖(peer dependency)和 dev 依赖且范围一致、使用根 `package.json` 的版本、设置 `type: module`;vendor 包仅检查是否为私有。 - 所有 CI、lefthook 钩子、`package.json` 脚本和文档中的 `yarn …` 动词变为 `pnpm …` / `pnpm run …`。`yarn.lock` → `pnpm-lock.yaml`(lockfile v9)。`.gitignore` 将 `.yarn/` 换为 `.pnpm-store/`。vendor README(如 `vendor/cordis/README.md`)按 Vendoring Policy 保持其上游 `yarn` 示例不变。 @@ -40,4 +40,4 @@ Status: implemented 在快速本地磁盘上,pnpm 的内容寻址 store 通常在冷/热安装中胜出,尤其在多个检出之间的**磁盘占用**方面优势明显(一个全局 store 通过硬链接接入每个 `node_modules`,而 Yarn 每个 worktree 复制约 279 MB——部分开发者经常为本仓库保持约 10 个或更多 worktree)。该去重优势在上述迁移时数据中**未能**体现,因为测试 store 和 `node_modules` 位于不同文件系统,硬链接失效;在单文件系统的开发机或 CI 缓存上则适用。诚实的总结:在我们的 NFS 开发文件系统上,安装速度在噪声范围内不分伯仲;迁移的理由是生态对齐、幻影依赖安全性和跨检出磁盘去重,而非原始安装时间的胜出。 -所有质量门禁(constraints、类型检查、lint、doc-sync、达到 100% 的 test:coverage、构建、knip、publint 以及已构建应用的冒烟测试)均在 pnpm 下通过,证明更换链接器没有引入幻影依赖故障。 +所有质量门禁(constraints、类型检查、lint、doc-sync、达到 100% 的 test:coverage、构建、publint 以及已构建应用的冒烟测试)均在 pnpm 下通过,证明更换链接器没有引入幻影依赖故障。 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index d849fd4bb1..f662ae9c7d 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.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 .agents/notes/implemented/process/2026-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: 10b8ac8761ef6cc522e752780324c11dfda23622 -2026-07-13-documentation-site-projection.zh.md: 4a03a47e6293b613822e034c710cba6ed7084008 +2026-07-13-documentation-site-projection.md: c296ea55f2e7882e4328cc9ec4bd81382598f674 +2026-07-13-documentation-site-projection.zh.md: 683cb9215f702180706db4349ee6454c7f580762 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 10b8ac8761..c296ea55f2 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 @@ -24,7 +24,7 @@ The projector parses Markdown links without reserializing the document. A link t `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. -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. +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. 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. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index 4a03a47e62..683cb9215f 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -24,7 +24,7 @@ Status: implemented `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 -Mermaid 渲染权威图表。网站工作区显式声明 `vitepress-plugin-mermaid` 要求 Vite 预打包的 5 个包,因为 pnpm 的严格依赖隔离会使本地开发服务器无法使用这些传递依赖;Knip 将这种仅运行时使用记录为有意的依赖例外。 +Mermaid 渲染权威图表。网站工作区显式声明 `vitepress-plugin-mermaid` 要求 Vite 预打包的 5 个包,因为 pnpm 的严格依赖隔离会使本地开发服务器无法使用这些传递依赖。 网站发布与网站构建保持分离。专用 GitHub Actions 工作流运行现有文档门禁,将 `website/.dist` 作为 Pages 产物上传,并只在构建成功后部署。`actions/configure-pages` 在构建时向 VitePress 提供目标位置的 base path,因此私有 Pages 源站、未来的公开项目路径和自定义域名不需要各自的检入配置。Pages 可见性仍是仓库托管设置,而不是工作流权限。 diff --git a/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.i18n.yaml index bc066b13ef..1ae963f232 100644 --- a/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.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 .agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.md -2026-07-30-independent-ci-consumer-build.md: ea87d8051a30c282bdf57ddc3226072be8cb7f24 -2026-07-30-independent-ci-consumer-build.zh.md: b0aa6c453573e089d0352beaa74a6eab769453ae +2026-07-30-independent-ci-consumer-build.md: a6d8379579714af4f66807c0110ce17ca3604f30 +2026-07-30-independent-ci-consumer-build.zh.md: 67817c69797a28b3c9e7e37f2a0f84218aa1bf7a diff --git a/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.md b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.md index ea87d8051a..a6d8379579 100644 --- a/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.md +++ b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.md @@ -6,7 +6,7 @@ English | [中文](2026-07-30-independent-ci-consumer-build.zh.md) ## Problem -The [larger-runner topology](2026-07-22-evidence-based-larger-hosted-runners.md) gave the static and built-consumer inventories separate jobs, but the static job owned their shared build. It uploaded the emitted tree only after every static gate completed, and the consumer job declared a job-level dependency before restoring that tree. Compiled-output snapshots and publication checks genuinely require a complete build; they do not require runtime-closure checks, documentation generation, module-graph verification, or Knip. +The [larger-runner topology](2026-07-22-evidence-based-larger-hosted-runners.md) gave the static and built-consumer inventories separate jobs, but the static job owned their shared build. It uploaded the emitted tree only after every static gate completed, and the consumer job declared a job-level dependency before restoring that tree. Compiled-output snapshots and publication checks genuinely require a complete build; they do not require runtime-closure checks, documentation generation, or module-graph verification. That wider dependency made runner availability part of the required critical chain. In one failover run, static waited 8 minutes 1 second for a runner and ran for 1 minute 41 seconds; only then could consumers enter the same shared pool, where they waited another 10 minutes 34 seconds before running for 1 minute 58 seconds. Reusing the static build saved repository work but serialized two independent runner allocations. diff --git a/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.zh.md b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.zh.md index b0aa6c4535..67817c6979 100644 --- a/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-independent-ci-consumer-build.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[大型运行器拓扑](2026-07-22-evidence-based-larger-hosted-runners.md)将静态门禁清单和构建后消费方清单分配给不同作业,但二者共用的构建由静态作业负责。静态作业要等所有静态门禁完成后才上传生成的目录树,消费方作业则在恢复该目录树前声明了作业级依赖。基于编译输出的快照与发布校验确实需要完整构建,但不依赖运行时依赖闭包检查、文档生成、模块图验证或 Knip。 +[大型运行器拓扑](2026-07-22-evidence-based-larger-hosted-runners.md)将静态门禁清单和构建后消费方清单分配给不同作业,但二者共用的构建由静态作业负责。静态作业要等所有静态门禁完成后才上传生成的目录树,消费方作业则在恢复该目录树前声明了作业级依赖。基于编译输出的快照与发布校验确实需要完整构建,但不依赖运行时依赖闭包检查、文档生成或模块图验证。 这项过宽的依赖使运行器可用性成为必需关键链的一环。一次故障切换运行中,静态作业等待运行器 8 分 1 秒,随后运行 1 分 41 秒;直到此时,消费方作业才能进入同一个共享池,它又等待 10 分 34 秒,随后运行 1 分 58 秒。复用静态作业的构建省去了部分仓库工作,却让两次原本相互独立的运行器分配串行发生。 diff --git a/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.i18n.yaml b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.i18n.yaml index 3518cf373e..d465416bae 100644 --- a/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.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 .agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.md -2026-08-06-coverage-uncovered-locations.md: 7a4bd7317bf66d090d73a6a05f361ccc1582f1c4 -2026-08-06-coverage-uncovered-locations.zh.md: 12560dd9297076746d58f78f5ce3538346082ac8 +2026-08-06-coverage-uncovered-locations.md: d4036ab17ac2d4aa6a0428fcbbdd3fc6d7e6968d +2026-08-06-coverage-uncovered-locations.zh.md: 6a10efd216501cb45837be2f7505531c398bf6cd diff --git a/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.md b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.md index 7a4bd7317b..d4036ab17a 100644 --- a/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.md +++ b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.md @@ -21,7 +21,7 @@ Output conventions: - An implicit branch arm (such as a missing else) may carry no location; the reporter falls back to the branch's own span so the record stays clickable; branch records are annotated with the branch type and `path k/n`. - Records within a file are sorted by line, then column; there is no cap on the count. -Two companion changes: the root `package.json` adds `istanbul-lib-report` as a devDependency (under pnpm's strict layout, `scripts/` cannot reach nested dependencies); the root workspace's entry/project globs in `knip.json` gain `scripts/**/*.cjs`, making the file and its dependencies visible to the hygiene gate. +The root `package.json` declares `istanbul-lib-report` as a devDependency because pnpm's strict layout prevents `scripts/` from reaching nested dependencies. CJS is a forced shape, and a justified exception to the ESM-everywhere discipline: istanbul loads custom reporters via a bare `require()` outside the tsx/Vite pipeline, where TypeScript cannot participate; the namespace object `require(esm)` returns also fails its `new Cons(cfg)` construction — CommonJS is the only reliable shape. diff --git a/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.zh.md b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.zh.md index 12560dd929..6a10efd216 100644 --- a/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.zh.md +++ b/.agents/notes/implemented/process/2026-08-06-coverage-uncovered-locations.zh.md @@ -21,7 +21,7 @@ per-file 100% 覆盖率门禁失败时,vitest 只输出文件级错误行(`E - 隐式分支臂(如缺少 else 的情况)可能不带位置,reporter 会回退到分支自身的 span,保证记录仍可点击;分支记录标注类型与 `path k/n`。 - 同文件内记录按行、列排序;不设条数上限。 -配套两处:根 `package.json` 增补 devDependency `istanbul-lib-report`(pnpm 严格布局下 `scripts/` 摸不到嵌套依赖);`knip.json` 根 workspace 的 entry/project 通配增加 `scripts/**/*.cjs`,使该文件及其依赖对 hygiene 门禁可见。 +根 `package.json` 将 `istanbul-lib-report` 声明为 devDependency,因为 pnpm 的严格布局使 `scripts/` 无法访问嵌套依赖。 CJS 是被迫的形态,也是 ESM-everywhere 纪律的一个有据例外:istanbul 在 tsx/Vite 流水线之外用裸 `require()` 装载自定义 reporter,TypeScript 无法参与;`require(esm)` 返回的命名空间对象也过不了它的 `new Cons(cfg)` 构造,CommonJS 是唯一可靠形态。 diff --git a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.i18n.yaml index 74756fdd4a..731f6d0414 100644 --- a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.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 .agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md -2026-08-10-vendor-package-rescope.md: 3ad442a681229b7b9df8feb1c487a925b6123f23 -2026-08-10-vendor-package-rescope.zh.md: d45fd3c389204e832917f0749c7d5bc360dea3a1 +2026-08-10-vendor-package-rescope.md: 5b5bc186bd01d0509d127a0a866da99509498c69 +2026-08-10-vendor-package-rescope.zh.md: 481cd150d4d7ff5a44aedca4651b1754c39753ee diff --git a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md index 3ad442a681..5b5bc186bd 100644 --- a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md +++ b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md @@ -34,7 +34,7 @@ Markdown splits along what a reader does with it. Every fence follows the rename - No upstream name remains in the publication set. `publish-npm-baseline.ts` now requires every published package to be `@deepseek-ai/*` with no vendored exemption, so regressing the rename fails before packing. - The `vendor/README.md` manifest table gains an upstream-name column; `gen-third-party-notices` parses six columns and renders that name into `THIRD_PARTY_NOTICES.md`, keeping MIT attribution pointed at each fork's origin rather than our scope. -- `pnpm-workspace.yaml` drops the `cordis` and `@cordisjs/plugin-loader` `minimumReleaseAgeExclude` entries, which can no longer be fetched from a registry, and `knip.json` drops the `@cordisjs/.+` ignore pattern that `@deepseek-ai/.+` already covers. +- `pnpm-workspace.yaml` drops the `cordis` and `@cordisjs/plugin-loader` `minimumReleaseAgeExclude` entries, which can no longer be fetched from a registry. - Upstream sync follows the procedure in `vendor/README.md` with one added obligation in step 3: re-apply the rename over the copied sources with `pnpm run rescope-vendor --apply`, whose mapping and the table's two name columns must agree. - **Returning to the official upstream packages** means applying that mapping in reverse — `pnpm run rescope-vendor --apply --reverse` — then restoring the two `minimumReleaseAgeExclude` entries and relaxing the publication-set assertion. It spans roughly 1300 files, so replay it with the script rather than by hand. diff --git a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.zh.md b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.zh.md index d45fd3c389..481cd150d4 100644 --- a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.zh.md @@ -34,7 +34,7 @@ Markdown 按「读者拿它做什么」一分为二。围栏一律跟着改, - 发布集里不再有任何上游名:`publish-npm-baseline.ts` 现在无条件要求每个待发包都是 `@deepseek-ai/*`,vendored 包不再豁免,改名一旦回退就会在打包前失败。 - `vendor/README.md` 的清单表新增「上游名」列,`gen-third-party-notices` 随之解析六列并把上游名渲进 `THIRD_PARTY_NOTICES.md`;MIT 归属指向 fork 的来源,而不是我们的 scope。 -- `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude` 删去 `cordis` 与 `@cordisjs/plugin-loader` 两条:改名后这两个名字永远不从 registry 取。`knip.json` 的 `@cordisjs/.+` 忽略模式同理删除,已被 `@deepseek-ai/.+` 覆盖。 +- `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude` 删去 `cordis` 与 `@cordisjs/plugin-loader` 两条:改名后这两个名字永远不从 registry 取。 - 上游 sync 照 `vendor/README.md` 的流程走,第 3 步多一项:对拷进来的源码重跑 `pnpm run rescope-vendor --apply`,脚本里的映射与清单表两列名字必须一致。 - **要回到官方上游包**时反着跑这份映射——`pnpm run rescope-vendor --apply --reverse`——再补回 `minimumReleaseAgeExclude` 两条、放开发布集对 `@deepseek-ai/*` 的断言。改写量约 1300 个文件,用脚本重放而不是手改。 diff --git a/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.i18n.yaml b/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.i18n.yaml index 085b5ba241..28f8b4a1a6 100644 --- a/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.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 .agents/notes/implemented/process/2026-08-12-face-named-client-test-files.md -2026-08-12-face-named-client-test-files.md: e83ebe4fda25218d96bab546af025b59064ca18f -2026-08-12-face-named-client-test-files.zh.md: 7062c67f9c98d87f480e119df807955c1a10820a +2026-08-12-face-named-client-test-files.md: 9bc82ba4297059151f787be53f89736cd5a6935b +2026-08-12-face-named-client-test-files.zh.md: c6f62b60554146e9006ef43498f752712dd1c23c diff --git a/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.md b/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.md index e83ebe4fda..9bc82ba429 100644 --- a/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.md +++ b/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.md @@ -56,6 +56,6 @@ The rule costs a suffix on every client test filename and buys a mechanical part The Host program now sees 11 files under `packages/client` (the four Host-face specs and the carrier's Host-face declarations, resolved through its project reference) instead of the 60 that leaked in while the exclusion was pattern-based but the filenames were not. -vitest discovers every renamed file through `**/*.spec.{ts,tsx}`, so no test configuration changed; the full client suite runs 235 files and 3181 tests. knip's per-workspace `tests/**/*.spec.{ts,tsx}` entry patterns match the new names for the same reason. +vitest discovers every renamed file through `**/*.spec.{ts,tsx}`, so no test configuration changed; the full client suite runs 235 files and 3181 tests. An unsuffixed new test is the failure mode this leaves open: it type-checks in the Host program against Client source instead of failing loudly. diff --git a/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.zh.md b/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.zh.md index 7062c67f9c..c6f62b6055 100644 --- a/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.zh.md +++ b/.agents/notes/implemented/process/2026-08-12-face-named-client-test-files.zh.md @@ -56,6 +56,6 @@ Status: implemented Host 程序现在在 `packages/client` 下看到 11 个文件(4 个 Host 面 spec,以及经工程引用解析到的载体 Host 面声明),而在排除按模式、文件名却不按模式的状态下漏进来的是 60 个。 -vitest 经 `**/*.spec.{ts,tsx}` 仍能发现每个改名后的文件,因此测试配置没有变化;完整 client 套件跑 235 个文件、3181 个用例。knip 各 workspace 的 `tests/**/*.spec.{ts,tsx}` entry 模式同理匹配新名字。 +vitest 经 `**/*.spec.{ts,tsx}` 仍能发现每个改名后的文件,因此测试配置没有变化;完整 client 套件跑 235 个文件、3181 个用例。 这条规则留下的失败模式是新增一个不带后缀的测试:它会在 Host 程序里针对 Client 源码通过类型检查,而不是显式报错。 diff --git a/.agents/notes/implemented/process/2026-08-19-remove-knip.i18n.yaml b/.agents/notes/implemented/process/2026-08-19-remove-knip.i18n.yaml new file mode 100644 index 0000000000..aee415539b --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-19-remove-knip.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 .agents/notes/implemented/process/2026-08-19-remove-knip.md +2026-08-19-remove-knip.md: 5e7e898e05a7e6a6e087b8784f7f49e71cf8fc37 +2026-08-19-remove-knip.zh.md: f6bca8661648efd565dc0bbec86ca2c76738ea85 diff --git a/.agents/notes/implemented/process/2026-08-19-remove-knip.md b/.agents/notes/implemented/process/2026-08-19-remove-knip.md new file mode 100644 index 0000000000..5e7e898e05 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-19-remove-knip.md @@ -0,0 +1,27 @@ +# Agent Note: Remove Knip from repository gates + +Status: implemented + +English | [中文](2026-08-19-remove-knip.zh.md) + +## Problem + +Knip derives unused files, exports, and dependencies from a static source graph. DeepSeek Harness also loads Cordis plugins from package manifests and configuration, emits Typert faces into `lib/`, splits Host and Client programs, and declares dependencies consumed only by generated or runtime-loaded code. The repository therefore needed workspace-specific entry lists and ignored-dependency exceptions to make supported paths pass the scan. Package and test-layout changes had to maintain that second approximation of the executable graph. + +The repository already has narrower checks for the failures it treats as release contracts: TypeScript and Oxlint validate source imports, workspace constraints validate manifests, `verify-optional-dependency-imports` validates optional imports, `verify-runtime-closure` validates runtime dependencies, `verify-client-packages` validates Client packaging, and publint validates published packages. The generic unused-code result is advisory, while its exceptions are required maintenance. + +## Decision + +Knip is not a repository dependency or quality gate. The root manifest has no Knip script or devDependency, the gate graph and `hygiene` command do not invoke it, and the repository carries no Knip configuration. Package guidance and comments describe the runtime or generated-code requirement directly instead of teaching Knip exceptions. + +The repository has no repo-wide static check for unused files, exports, or dependencies. Maintainers establish that a removal is safe from call sites, manifests, configuration, generated artifacts, tests, documentation, and Cordis Loader paths. + +## Alternatives considered + +**Keep Knip and its exception inventory.** This retains one broad advisory signal, but every supported dynamic or generated path needs configuration that restates facts already owned by manifests, build configuration, and package-specific checks. The exception inventory makes ordinary package changes depend on a source graph that does not represent the assembled application. + +## Consequences + +CI and `hygiene` run one fewer command, and package changes no longer update a parallel entrypoint and dependency-exception inventory. The repository gives up automatic broad unused-code and unused-dependency reports; reviewers and simplification work must prove removals against the real loading paths. + +A future unused-code check must understand manifest-driven Cordis loading, generated outputs, and the Host/Client split without a per-workspace ignore inventory. Until then, a dependency with no source import is not dead-code evidence by itself. diff --git a/.agents/notes/implemented/process/2026-08-19-remove-knip.zh.md b/.agents/notes/implemented/process/2026-08-19-remove-knip.zh.md new file mode 100644 index 0000000000..f6bca86616 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-19-remove-knip.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 从仓库门禁中移除 Knip + +Status: implemented + +[English](2026-08-19-remove-knip.md) | 中文 + +## 问题 + +Knip 从静态源码图推导未使用文件、export 和依赖。DeepSeek Harness 还会从包 manifest(元数据清单)和配置中装载 Cordis 插件、向 `lib/` 生成 Typert 契约面、拆分 Host 与 Client 程序,并声明仅由生成代码或运行时装载代码消费的依赖。因此,仓库必须维护 workspace 专用入口列表和忽略依赖例外,才能让受支持路径通过扫描。包与测试布局变化还必须维护这份对可执行图的第二重近似描述。 + +仓库已经为视作发布约定的故障提供了更窄的检查:TypeScript 与 Oxlint 验证源码 import,workspace 约束验证 manifest,`verify-optional-dependency-imports` 验证可选 import,`verify-runtime-closure` 验证运行时依赖,`verify-client-packages` 验证 Client 打包,publint 验证发布包。通用未使用代码结果只是建议,而其例外却是必需维护项。 + +## 决策 + +Knip 不再是仓库依赖或质量门禁。根 manifest 不含 Knip 脚本或 devDependency,门禁图和 `hygiene` 命令不调用它,仓库也不携带 Knip 配置。包指南和注释直接描述运行时或生成代码要求,不再讲解 Knip 例外。 + +仓库没有检查全仓未使用文件、export 或依赖的静态门禁。维护者需要从调用点、manifest、配置、生成产物、测试、文档和 Cordis Loader 路径证明一项删除是安全的。 + +## 考虑过的替代方案 + +**保留 Knip 及其例外清单。** 这会保留一项宽泛的建议信号,但每条受支持的动态或生成路径都需要配置,而这些配置会重述 manifest、构建配置和包专用检查已经拥有的事实。这份例外清单使普通包变更依赖一个无法表示组装后应用的源码图。 + +## 后果 + +CI 和 `hygiene` 少运行一个命令,包变更也不再更新一份并行的入口与依赖例外清单。仓库放弃自动生成宽泛的未使用代码与未使用依赖报告;评审与简化工作必须根据真实装载路径证明删除安全。 + +未来的未使用代码检查必须理解 manifest 驱动的 Cordis 装载、生成输出与 Host/Client 拆分,且不需要逐 workspace 忽略清单。在此之前,缺少源码 import 的依赖本身不能证明该依赖是死代码。 diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml index 98087b0c9b..93d22af1ab 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.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 .agents/notes/proposed/process/2026-06-20-discover-package-inventory.md -2026-06-20-discover-package-inventory.md: 7de865e43f87f0e41fad43b3786b9825509e9d50 -2026-06-20-discover-package-inventory.zh.md: fed3d9dd8740b2dc22434f8af7e1e8c025502f61 +2026-06-20-discover-package-inventory.md: 1e9ba6ba8f772dc150052ad017cd93dd32347173 +2026-06-20-discover-package-inventory.zh.md: 3bf9d029cf7fd4515c9626e5f769f7040819cfb3 diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md index 7de865e43f..1e9ba6ba8f 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.md @@ -6,7 +6,7 @@ English | [中文](2026-06-20-discover-package-inventory.zh.md) ## Problem -Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, and Knip overrides. Most restate package layout, manifest data, or aggregate command contents. Each new package therefore creates avoidable synchronization points. +Package and gate inventories are repeated across TypeScript project references, package docs, and CI prose. Most restate package layout, manifest data, or aggregate command contents. Each new package therefore creates avoidable synchronization points. The [package hierarchy](../../archived/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly the aggregate configs' (`tsconfig.host.json`, `tsconfig.client.json`) project `references`, which TypeScript requires as explicit arrays (no wildcard form). @@ -18,15 +18,12 @@ Make the remaining package/gate inventories discoverable. A single canonical sou The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. -One cataloged item needs no generator at all: folding the e2e entry glob into knip's default stanza deletes the per-package restatements outright. - ## Acceptance criteria - Aggregate-config project `references` are generated from the hierarchy (a generator emits them; a `--check` gate fails when the committed copy is stale), rather than hand-maintained. - Adding a package does not require editing a static package list for any gate. - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. -- `knip.json` carries a per-package override only where it encodes real information (an extra entry file, an ignored dependency), never a restatement of the default stanza. ## Risks diff --git a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md index fed3d9dd87..3bf9d029cf 100644 --- a/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md +++ b/.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md @@ -6,7 +6,7 @@ Status: proposed ## 问题 -包与门禁清单在 TypeScript project references、包文档、CI 描述和 Knip 覆盖项中反复出现。大多数只是重述包布局、manifest(元数据清单)数据或聚合命令内容。因此每新增一个包都会产生本可避免的同步点。 +包与门禁清单在 TypeScript project references、包文档和 CI 描述中反复出现。大多数只是重述包布局、manifest(元数据清单)数据或聚合命令内容。因此每新增一个包都会产生本可避免的同步点。 [包层级结构](../../archived/architecture/2026-06-20-package-hierarchy.md)已经手动消除了其中若干:`scripts/publint-all.ts` 现在从 `packages//` 布局推导列表,两份 `tsconfig` 的 `paths` 映射也合并为一个 `@deepseek-ai/dsh-*` 通配符。剩下的是无法用 glob 消除的清单,主要是聚合配置(`tsconfig.host.json`、`tsconfig.client.json`)中的项目引用(`references`)——TypeScript 要求它们是显式数组(没有通配符形式)。 @@ -18,15 +18,12 @@ Status: proposed 层级结构不需要编码关于包的所有事实,但应当编码宽泛的维护策略:core/product 包、集成包、能力 seam 包与 support/test/example 包不应在脚本能区分它们之前先要求一份手工维护的例外列表。 -有一项已编目的内容根本不需要生成器:将 e2e 入口 glob 折入 Knip 的默认配置段,即可直接删除逐包的重复声明。 - ## 验收标准 - 聚合配置的项目引用(`references`)由层级结构生成(生成器输出它们;`--check` 门禁在提交副本陈旧时报错),而非手工维护。 - 新增一个包时,不需要为任何门禁编辑静态包列表。 - 文档描述真源,而非重复生成的清单。 - CI 调用聚合命令,由这些命令自行管理其子门禁列表。 -- `knip.json` 仅在编码真实信息(额外入口文件、被忽略的依赖)时才携带逐包覆盖项,绝不重述默认配置段。 ## 风险 diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 2a10999c27..36aef2a7e4 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -28,7 +28,7 @@ A strong simplification removes, folds, or demotes something real and has clear - Hand-rolled code reimplements what a well-maintained external package or a Node builtin at the engine floor already provides, and the swap would delete the implementation plus its dedicated tests ([dependency policy](../../notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md)). - The simplified behavior may differ slightly, but the new behavior is still reasonable and easier to explain. -Thin candidates are usually not enough for an Agent Note: deleting one typo, running `knip` once, removing an intentionally documented backend/adapter, or flagging "this looks complex" without call-site proof. +Thin candidates are usually not enough for an Agent Note: deleting one typo, removing an intentionally documented backend/adapter, or flagging "this looks complex" without call-site proof. ## Survey Broadly @@ -69,7 +69,7 @@ For every symbol or behavior, classify consumers before writing: - Non-production corpus: tests, README/docs, Agent Notes, snapshots, generated expected outputs, and comments. - Ambiguous corpus: examples and scripts that may be product smoke paths. Inspect usage before classifying. -Use `rg` first. Good searches include the exact symbol, event name, package name, config key, method name with both `.name(` and `name(`, and any wire strings. Then read the call sites. `knip` can help, but it is not a substitute for understanding public interfaces, dynamic event names, tests, docs, and Cordis loader paths. +Use `rg` first. Good searches include the exact symbol, event name, package name, config key, method name with both `.name(` and `name(`, and any wire strings. Then read the call sites, public interfaces, dynamic event names, tests, docs, and Cordis loader paths. Reject or downgrade a candidate when: diff --git a/AGENTS.md b/AGENTS.md index 99b31077db..b57bf07c08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection pnpm run build # tsc emits lib/types, tsdown bundles runtime -pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check +pnpm run hygiene # publint + workspace/package/dependency checks + NodeNext consumer check pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6ad4739c19..948efa203a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -150,7 +150,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | | [`jsdom`](https://github.com/jsdom/jsdom) | MIT | -| [`knip`](https://github.com/webpro-nl/knip) | ISC | | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 5cdb1404f8..a28ba7c78d 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.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 docs/cookbook/adding-a-package.md -adding-a-package.md: a78695735957395c5c900c3294b6778904557f85 -adding-a-package.zh.md: b7a749220fdc1581875851e2e4a7189ba7642b2d +adding-a-package.md: 566b3bce3e7c7c494d5894ca841b31a26cde88dc +adding-a-package.zh.md: 1c19931314a92545f284e76cea9747bcff4685b9 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index a786957359..566b3bce3e 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -32,7 +32,6 @@ In-package relative imports use explicit `.ts` specifiers in source (for example |---|---| | `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages//*/src` candidate to the `@deepseek-ai/dsh-*` wildcard | | `tsconfig.host.json` (Host package) or `tsconfig.client.json` (Client package) | add `{ "path": "./packages//" }` to `references` — an ordinary package belongs to exactly one aggregate, never both. `api/remotes` uses a repository-specific split because the Host generates a contract that the Client consumes in a later phase; new packages must not copy it ([layout](../development.md#typescript-project-layout)) | -| `knip.json` | only if the package has entrypoints that repository discovery does not already cover | A `packages/client/*` package additionally extends `tsconfig.base.client.json` instead of `tsconfig.base.json`, and a client plugin package declares `dsh.client` in package.json, exports `./client`, and calls the shared tsdown preset (`packages/client/tsdown.client.ts`) — see [packages/client/AGENTS.md](../../packages/client/AGENTS.md) for the client-side contract. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index b7a749220f..1c19931314 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -32,7 +32,6 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c |---|---| | `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages//*/src` 候选路径 | | `tsconfig.host.json`(Host 包)或 `tsconfig.client.json`(Client 包) | 在 `references` 中添加 `{ "path": "./packages//" }`——普通包恰好属于一个 aggregate,绝不两个都加。`api/remotes` 因 Host 生成约定与 Client 消费约定之间存在顺序依赖而使用仓库专属拆分,新增包不得仿照([布局](../development.md#typescript-project-layout)) | -| `knip.json` | 仅当包有仓库发现机制尚未覆盖的入口时需要 | `packages/client/*` 包改为 extends `tsconfig.base.client.json`(而非 `tsconfig.base.json`);client 插件包还需在 package.json 声明 `dsh.client`、导出 `./client`、调用共享 tsdown preset(`packages/client/tsdown.client.ts`)——client 侧见 [packages/client/AGENTS.md](../../packages/client/AGENTS.md)。 diff --git a/knip.json b/knip.json deleted file mode 100644 index 7969b4f230..0000000000 --- a/knip.json +++ /dev/null @@ -1,790 +0,0 @@ -{ - "$schema": "https://unpkg.com/knip@5/schema.json", - "exclude": [ - "duplicates" - ], - "ignoreBinaries": [ - "bwrap", - "icacls", - "musl-gcc", - "python3", - "sandbox-exec", - "tar", - "taskkill", - "where.exe" - ], - "ignoreWorkspaces": [ - "vendor/*", - "python/sdk-runtime" - ], - "ignoreDependencies": [ - "@yarnpkg/cli-dist", - "lightningcss" - ], - "workspaces": { - ".": { - "entry": [ - "scripts/**/*.mjs", - "scripts/**/*.cjs" - ], - "project": [ - "scripts/**/*.ts", - "scripts/**/*.mjs", - "scripts/**/*.cjs" - ] - }, - "examples": { - "entry": [ - "headless-agent/tests/fixtures/cli-mock-llm.ts", - "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", - "headless-agent/tests/fixtures/subagent-diagnostic-agent.ts", - "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", - "headless-agent/tests/fixtures/subagent-settlement-fence.ts", - "headless-agent/tests/fixtures/workspace-context-resume-agent.ts", - "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", - "headless-agent/tests/fixtures/session-telemetry-otel-driver.ts", - "headless-agent/tests/fixtures/telemetry-redact-rule.ts", - "headless-agent/tests/fixtures/e2b/e2b/bin.ts", - "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", - "acp-agent/tests/fixtures/child-question-tripwire.ts", - "acp-agent/tests/fixtures/parent-sandbox-override.ts", - "acp-agent/tests/fixtures/partial-landlock-sandbox.ts", - "acp-agent/tests/fixtures/subagent-durability-failure.ts", - "acp-agent/tests/fixtures/subagent-result-diagnostic.ts", - "acp-agent/tests/fixtures/subagent-report-fence.ts", - "acp-agent/tests/fixtures/subagent-settlement-marker.ts", - "acp-agent/tests/fixtures/workspace-context-compaction.ts", - "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", - "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", - "acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts", - "acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts", - "acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts", - "acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts", - "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", - "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts", - "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts", - "*/tests/**/*.e2e.ts", - "*/tests/**/*.snapshot.ts" - ], - "project": [ - "**/*.ts" - ], - "ignoreDependencies": [ - "@deepseek-ai/.+" - ] - }, - "packages/util/home": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/host/webserver": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/host/directory-picker-auto": { - "ignoreDependencies": [ - "@deepseek-ai/dsh-client-ui-directory-picker-browse", - "@deepseek-ai/dsh-client-ui-directory-picker-native" - ] - }, - "packages/extensions/cordis-host-runner": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ], - "ignoreDependencies": [ - "zod" - ] - }, - "packages/host/directory-picker-native": { - "entry": [ - "tests/**/*.spec.{ts,tsx}", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.{ts,tsx}", - "tests/**/*.{ts,tsx}" - ] - }, - "packages/client/web-ui": { - "entry": [ - "tests/**/*.spec.{ts,tsx}" - ], - "project": [ - "src/**/*.{ts,tsx}", - "tests/**/*.{ts,tsx}" - ] - }, - "packages/client/runtime": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/api/remotes": { - "entry": [ - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ], - "ignoreDependencies": [ - "@deepseek-ai/dsh-api-gateway" - ] - }, - "packages/client/ui-primitives": { - "entry": [ - "tests/**/*.spec.tsx" - ], - "project": [ - "src/**/*.ts", - "src/**/*.tsx", - "tests/**/*.tsx" - ] - }, - "packages/client/ui-goal": { - "entry": [ - "tests/**/*.spec.tsx" - ], - "project": [ - "src/**/*.ts", - "src/**/*.tsx", - "tests/**/*.tsx" - ] - }, - "packages/client/ui-directory-picker-browse": { - "entry": [ - "tests/**/*.spec.tsx" - ], - "project": [ - "src/**/*.ts", - "src/**/*.tsx", - "tests/**/*.tsx" - ] - }, - "packages/client/ui-directory-picker-native": { - "entry": [ - "tests/**/*.spec.tsx" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.tsx" - ] - }, - "packages/client/ui-deliverables": { - "entry": [ - "tests/**/*.spec.tsx" - ], - "project": [ - "src/**/*.ts", - "src/**/*.tsx", - "tests/**/*.tsx" - ] - }, - "packages/client/ui-workflow-run": { - "entry": [ - "tests/**/*.spec.tsx" - ], - "project": [ - "src/**/*.ts", - "src/**/*.tsx", - "tests/**/*.tsx" - ] - }, - "packages/client/ui-layout": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.spec.tsx" - ], - "project": [ - "src/**/*.ts", - "src/**/*.tsx", - "tests/**/*.ts", - "tests/**/*.tsx" - ] - }, - "website": { - "project": [ - "**/*.ts" - ], - "ignoreDependencies": [ - "@braintree/sanitize-url", - "cytoscape", - "cytoscape-cose-bilkent", - "dayjs", - "debug" - ] - }, - "packages/*/*": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/core/tools": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/typert/generator": { - "entry": [ - "tests/**/*.spec.ts", - "tests/fixtures/type-model/**/*.ts", - "tests/fixtures/remote-model/**/*.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/shell/bash-sandbox": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/shell/pwsh-sandbox": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/e2b/e2b": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/context/time-context": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/context/tmux-context": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/lsp/lsp-stdio": { - "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/session/session-telemetry-otel": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/util/brand": { - "project": [ - "src/**/*.ts" - ] - }, - "packages/attachment/attachment": { - "project": [ - "src/**/*.ts" - ] - }, - "packages/util/timeout": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/util/output-retention": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/test-support/acp-snapshot": { - "entry": [ - "tests/**/*.spec.ts", - "tests/fixtures/fake-acp-agent.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/test-support/loader-smoke": { - "entry": [ - "tests/**/*.spec.ts", - "tests/fixtures/*.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/core/agent-loop": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/goal/goal": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/goal/goal-round-driver": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/goal/tool-goal": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/session-query/session-query-sqlite": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/code-runtime/code-runtime-worker-thread": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/code-runtime/code-runtime-python": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/llm/llm-deepseek": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/llm/llm-pi-ai": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/session/session-title-first-prompt-llm": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/context/agent-instructions": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/session/session-checkpoint-policy": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/util/home-paths": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/web/web-search-exa": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/web/web-search-perplexity": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/workflow/workflow-worker-thread": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/web/web-search-deepseek": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/examples/acp-demo": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/examples/agent-spine-demo": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/sdk/server": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/interaction/commands": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ], - "ignoreDependencies": [ - "zod" - ] - }, - "packages/examples/jsonrpc-demo": { - "project": [ - "src/**/*.ts" - ] - }, - "packages/subagent/subagent-spawn-in-process": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/subagent/subagent-acp": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts", - "tests/mock-acp-server.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/subagent/subagent-codex": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/subagent/subagent-claude-code": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/fs/tool-fs": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/fs/tool-fs-search": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/mcp/mcp-client": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts", - "tests/fixture-server.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ], - "ignoreDependencies": [ - "@modelcontextprotocol/server-everything", - "@modelcontextprotocol/server-filesystem" - ] - }, - "packages/client/web": { - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/client/ui-renderer": { - "entry": [ - "tests/**/*.spec.tsx" - ], - "project": [ - "src/**/*.ts", - "src/**/*.tsx", - "tests/**/*.tsx" - ] - }, - "packages/client/ui-settings": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "apps/web": { - "entry": [ - "tests/**/*.e2e.ts", - "tests/**/*.perf.ts", - "tests/**/*.snapshot.ts", - "tests/support.ts", - "src/node-module-stub.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ], - "ignoreDependencies": [ - "@deepseek-ai/dsh-client-ui-primitives", - "@deepseek-ai/dsh-client-ui-slots", - "@types/react", - "@types/react-dom", - "react", - "react-dom" - ] - }, - "apps/cli": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts", - "tests/**/*.snapshot.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ], - "ignoreDependencies": [ - "@deepseek-ai/.+" - ] - }, - "packages/client/modules": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/client/hmr": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/subagent/subagent-dsh-sdk": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/bundle/base": { - "ignoreDependencies": [ - "@deepseek-ai/.+" - ] - }, - "packages/bundle/headless": { - "ignoreDependencies": [ - "@deepseek-ai/dsh-code-runtime-worker-thread" - ] - }, - "packages/bundle/web-app": { - "ignoreDependencies": [ - "@deepseek-ai/.+" - ] - } - } -} diff --git a/package.json b/package.json index 3ec9a6c1c5..5323718fc6 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,6 @@ "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", "check:node-compat": "tsx scripts/run-gates.ts node-compat", - "knip": "knip --treat-config-hints-as-errors", "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "npm run build:lib:host && npm run doc-typecheck:contracts-ready", "doc-typecheck:contracts-ready": "tsx scripts/doc-typecheck.ts", @@ -129,7 +128,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && 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-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-client-packages && pnpm run verify-vendored-links", + "hygiene": "pnpm run rescope-vendor:check && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && 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-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-client-packages && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "release:dsh": "tsx scripts/release/bump.ts --family dsh", "release:vendor": "tsx scripts/release/bump.ts --family vendor", @@ -165,7 +164,6 @@ "js-yaml": "^4.2.0", "jscpd": "^5.0.12", "jsdom": "29.1.1", - "knip": "^6.16.1", "lefthook": "^2.1.9", "lightningcss": "^1.32.0", "mdast-util-from-markdown": "^2.0.3", diff --git a/packages/extensions/cordis-host-runner/README.i18n.yaml b/packages/extensions/cordis-host-runner/README.i18n.yaml index de2059d0cb..b41af70a83 100644 --- a/packages/extensions/cordis-host-runner/README.i18n.yaml +++ b/packages/extensions/cordis-host-runner/README.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 packages/extensions/cordis-host-runner/README.md -README.md: f09e7506fe24676ea95b3ae490b55ae120bfad5f -README.zh.md: f60d3a64ecb1e90427b966c820a857e62d65b375 +README.md: 8e234a88a5f6b00e86b6423375f3addcd2c94c2a +README.zh.md: 5a99232005de1db1e343f896009bdad7beaeab85 diff --git a/packages/extensions/cordis-host-runner/README.md b/packages/extensions/cordis-host-runner/README.md index f09e7506fe..8e234a88a5 100644 --- a/packages/extensions/cordis-host-runner/README.md +++ b/packages/extensions/cordis-host-runner/README.md @@ -69,4 +69,4 @@ A host half that registers tools changes the next request's tool view, which inv - `runHostHalf` carries no request id, so "which request evaluated this host half" is attributed host-side to the most recently armed request for that definition; several concurrent run requests for one definition would need that rule revisited. - A success answer naming a superseded revision is refused (`accepted: false`) and leaves the request suspended, so the model's call ends only through a valid answer or its own cancellation. Settling it would take a fresh orchestration against the live revision, and no page does that today — the [browser half](../cordis-client-runner/README.md) does not read the ack — so in practice such a request is closed by another page's answer or by the caller's cancellation. - A browser half's declared `inject` is read from the plugin it returns in the page, so the announcement carries no service-declaration field at all. -- **`zod` is a runtime dependency of the generated TypeRT faces, not of `src`.** `./typert` and `./remote` resolve to `lib/typert.*.js`, which `tsc` emits unbundled with a bare `import { z } from 'zod'`, so the package must declare it (the `@deepseek-ai/dsh-goal` precedent) and `knip.json` must ignore it for this workspace — knip reads source, and these faces are build products. Nothing in `src` imports zod. +- **`zod` is a runtime dependency of the generated Typert faces, not of `src`.** `./typert` and `./remote` resolve to `lib/typert.*.js`, which `tsc` emits unbundled with a bare `import { z } from 'zod'`, so the package must declare it even though nothing in `src` imports zod. diff --git a/packages/extensions/cordis-host-runner/README.zh.md b/packages/extensions/cordis-host-runner/README.zh.md index f60d3a64ec..5a99232005 100644 --- a/packages/extensions/cordis-host-runner/README.zh.md +++ b/packages/extensions/cordis-host-runner/README.zh.md @@ -69,4 +69,4 @@ vm 沙箱隔离全局变量,但不是安全边界:Node 全局变量不存在 - `runHostHalf` 不携带 request id,因此「这个 host 半是哪次请求求值的」由 host 侧归因到该定义最近一次挂起的请求;若同一个定义出现多个并发 run 请求,这条规则需要重新审议。 - 命名了已被取代版本的成功结论会被拒绝(`accepted: false`)并让该请求继续挂起,因此模型这次调用只能靠一次有效作答或自身被取消才结束。要把它结算掉,需要对着存活版本重新走一遍编排,而当前没有任何页面会这么做——[浏览器半](../cordis-client-runner/README.md)不读这个 ack——所以这类请求实际上由别的页面作答、或由调用方取消来收尾。 - 浏览器半声明的 `inject` 是从它在页面里返回的插件上读出的,因此播报完全不携带服务声明字段。 -- **`zod` 是生成的 TypeRT 契约面的运行时依赖,不是 `src` 的依赖。** `./typert` 与 `./remote` 解析到 `lib/typert.*.js`,`tsc` 以不打包的形式产出它们,其中带有裸的 `import { z } from 'zod'`,所以本包必须声明它(沿用 `@deepseek-ai/dsh-goal` 的先例),而 `knip.json` 必须在这个 workspace 里忽略它:knip 读的是源码,而这些契约面是构建产物。`src` 里没有任何代码 import zod。 +- **`zod` 是生成的 Typert 契约面的运行时依赖,不是 `src` 的依赖。** `./typert` 与 `./remote` 解析到 `lib/typert.*.js`,`tsc` 以不打包的形式产出它们,其中带有裸的 `import { z } from 'zod'`,所以即使 `src` 中没有任何代码 import zod,本包也必须声明它。 diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 0044bc3e28..8e41edef80 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -43,9 +43,8 @@ export const BACKEND_PACKAGES: Record = { * Client surface package per resolved kind, mounted with its backend so one * resolved interaction still composes both faces. Declared as dependencies by * every composing app for the same reason as {@link BACKEND_PACKAGES}. Only the - * specifier is referenced here — the packages belong to the Client program, so - * no import of them exists on this side and knip needs them ignored for this - * workspace. + * specifier is referenced here because the packages belong to the Client + * program, so no import of them exists on this side. */ export const SURFACE_PACKAGES: Record = { native: '@deepseek-ai/dsh-client-ui-directory-picker-native', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36b32a5873..4be8e75125 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,9 +72,6 @@ importers: jsdom: specifier: 29.1.1 version: 29.1.1 - knip: - specifier: ^6.16.1 - version: 6.16.1 lefthook: specifier: ^2.1.9 version: 2.1.9 @@ -10368,133 +10365,6 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} - '@oxc-parser/binding-android-arm-eabi@0.133.0': - resolution: {integrity: sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxc-parser/binding-android-arm64@0.133.0': - resolution: {integrity: sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxc-parser/binding-darwin-arm64@0.133.0': - resolution: {integrity: sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxc-parser/binding-darwin-x64@0.133.0': - resolution: {integrity: sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxc-parser/binding-freebsd-x64@0.133.0': - resolution: {integrity: sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0': - resolution: {integrity: sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-parser/binding-linux-arm-musleabihf@0.133.0': - resolution: {integrity: sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-parser/binding-linux-arm64-gnu@0.133.0': - resolution: {integrity: sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-arm64-musl@0.133.0': - resolution: {integrity: sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxc-parser/binding-linux-ppc64-gnu@0.133.0': - resolution: {integrity: sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-riscv64-gnu@0.133.0': - resolution: {integrity: sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-riscv64-musl@0.133.0': - resolution: {integrity: sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxc-parser/binding-linux-s390x-gnu@0.133.0': - resolution: {integrity: sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-x64-gnu@0.133.0': - resolution: {integrity: sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxc-parser/binding-linux-x64-musl@0.133.0': - resolution: {integrity: sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxc-parser/binding-openharmony-arm64@0.133.0': - resolution: {integrity: sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxc-parser/binding-wasm32-wasi@0.133.0': - resolution: {integrity: sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@oxc-parser/binding-win32-arm64-msvc@0.133.0': - resolution: {integrity: sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxc-parser/binding-win32-ia32-msvc@0.133.0': - resolution: {integrity: sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxc-parser/binding-win32-x64-msvc@0.133.0': - resolution: {integrity: sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -12402,9 +12272,6 @@ packages: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true - fd-package-json@2.0.0: - resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -12451,11 +12318,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - formatly@0.3.0: - resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} - engines: {node: '>=18.3.0'} - hasBin: true - formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -12508,9 +12370,6 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - get-tsconfig@5.0.0-beta.5: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} @@ -12831,11 +12690,6 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - knip@6.16.1: - resolution: {integrity: sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - koffi@3.1.1: resolution: {integrity: sha512-mRX6AMeeKCxSOeOopqAcLAl5jcNvge7NAG8l7rF/8gGJATI0tdHFYjteIdE0mGOtWdsrJOij+PjnP8Q9c1gwgA==} @@ -13356,10 +13210,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - oxc-parser@0.133.0: - resolution: {integrity: sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw==} - engines: {node: ^20.19.0 || >=22.12.0} - oxc-resolver@11.20.0: resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} @@ -13753,10 +13603,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - smol-toml@1.6.1: - resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} - engines: {node: '>= 18'} - smol-toml@1.7.1: resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} engines: {node: '>= 18'} @@ -13817,10 +13663,6 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} - strip-json-comments@5.0.3: - resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} - engines: {node: '>=14.16'} - strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} @@ -13981,10 +13823,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - unbash@3.0.0: - resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} - engines: {node: '>=14'} - unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} @@ -14265,10 +14103,6 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - walk-up-path@4.0.0: - resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} - engines: {node: 20 || >=22} - web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -15754,70 +15588,6 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} - '@oxc-parser/binding-android-arm-eabi@0.133.0': - optional: true - - '@oxc-parser/binding-android-arm64@0.133.0': - optional: true - - '@oxc-parser/binding-darwin-arm64@0.133.0': - optional: true - - '@oxc-parser/binding-darwin-x64@0.133.0': - optional: true - - '@oxc-parser/binding-freebsd-x64@0.133.0': - optional: true - - '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0': - optional: true - - '@oxc-parser/binding-linux-arm-musleabihf@0.133.0': - optional: true - - '@oxc-parser/binding-linux-arm64-gnu@0.133.0': - optional: true - - '@oxc-parser/binding-linux-arm64-musl@0.133.0': - optional: true - - '@oxc-parser/binding-linux-ppc64-gnu@0.133.0': - optional: true - - '@oxc-parser/binding-linux-riscv64-gnu@0.133.0': - optional: true - - '@oxc-parser/binding-linux-riscv64-musl@0.133.0': - optional: true - - '@oxc-parser/binding-linux-s390x-gnu@0.133.0': - optional: true - - '@oxc-parser/binding-linux-x64-gnu@0.133.0': - optional: true - - '@oxc-parser/binding-linux-x64-musl@0.133.0': - optional: true - - '@oxc-parser/binding-openharmony-arm64@0.133.0': - optional: true - - '@oxc-parser/binding-wasm32-wasi@0.133.0': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - - '@oxc-parser/binding-win32-arm64-msvc@0.133.0': - optional: true - - '@oxc-parser/binding-win32-ia32-msvc@0.133.0': - optional: true - - '@oxc-parser/binding-win32-x64-msvc@0.133.0': - optional: true - '@oxc-project/types@0.133.0': {} '@oxc-project/types@0.135.0': {} @@ -17649,10 +17419,6 @@ snapshots: path-expression-matcher: 1.5.0 strnum: 2.4.0 - fd-package-json@2.0.0: - dependencies: - walk-up-path: 4.0.0 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -17704,10 +17470,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - formatly@0.3.0: - dependencies: - fd-package-json: 2.0.0 - formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -17767,10 +17529,6 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - get-tsconfig@4.14.0: - dependencies: - resolve-pkg-maps: 1.0.0 - get-tsconfig@5.0.0-beta.5: dependencies: resolve-pkg-maps: 1.0.0 @@ -17961,7 +17719,8 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 - jiti@2.7.0: {} + jiti@2.7.0: + optional: true jose@6.2.3: {} @@ -18079,22 +17838,6 @@ snapshots: khroma@2.1.0: {} - knip@6.16.1: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - formatly: 0.3.0 - get-tsconfig: 4.14.0 - jiti: 2.7.0 - oxc-parser: 0.133.0 - oxc-resolver: 11.20.0 - picomatch: 4.0.4 - smol-toml: 1.6.1 - strip-json-comments: 5.0.3 - tinyglobby: 0.2.17 - unbash: 3.0.0 - yaml: 2.9.0 - zod: 4.4.3 - koffi@3.1.1: optionalDependencies: '@koromix/koffi-darwin-arm64': 3.1.1 @@ -18778,31 +18521,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - oxc-parser@0.133.0: - dependencies: - '@oxc-project/types': 0.133.0 - optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.133.0 - '@oxc-parser/binding-android-arm64': 0.133.0 - '@oxc-parser/binding-darwin-arm64': 0.133.0 - '@oxc-parser/binding-darwin-x64': 0.133.0 - '@oxc-parser/binding-freebsd-x64': 0.133.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.133.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.133.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.133.0 - '@oxc-parser/binding-linux-arm64-musl': 0.133.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.133.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.133.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.133.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.133.0 - '@oxc-parser/binding-linux-x64-gnu': 0.133.0 - '@oxc-parser/binding-linux-x64-musl': 0.133.0 - '@oxc-parser/binding-openharmony-arm64': 0.133.0 - '@oxc-parser/binding-wasm32-wasi': 0.133.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.133.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.133.0 - '@oxc-parser/binding-win32-x64-msvc': 0.133.0 - oxc-resolver@11.20.0: optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.20.0 @@ -18824,6 +18542,7 @@ snapshots: '@oxc-resolver/binding-wasm32-wasi': 11.20.0 '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 + optional: true oxlint-tsgolint@7.0.2001: optionalDependencies: @@ -19324,8 +19043,6 @@ snapshots: signal-exit@4.1.0: {} - smol-toml@1.6.1: {} - smol-toml@1.7.1: {} source-map-js@1.2.1: {} @@ -19380,8 +19097,6 @@ snapshots: strip-final-newline@4.0.0: {} - strip-json-comments@5.0.3: {} - strnum@2.4.0: dependencies: anynum: 1.0.0 @@ -19511,8 +19226,6 @@ snapshots: typescript@6.0.3: {} - unbash@3.0.0: {} - unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 @@ -19858,8 +19571,6 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - walk-up-path@4.0.0: {} - web-streams-polyfill@3.3.3: {} webidl-conversions@8.0.1: {} diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 195a8bb23e..f5ed8d89ef 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -154,7 +154,6 @@ const POSTCONDITIONS: readonly PostCondition[] = [ { file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 }, // The vendored README owns this required entry; reject its deletion or duplication. { file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 }, - { file: 'knip.json', text: '@cordisjs', count: 0 }, { file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 }, // The preset ids in this table are product data, not package names. { file: 'packages/client/ui-agent-preset/tests/locales.client.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, @@ -196,37 +195,6 @@ const EXACT_EDITS: readonly ExactEdit[] = [ errors.push(\`\${label}: @deepseek-ai/cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`, expect: 1, }, - { - // The rescoped name is already covered by the `@deepseek-ai/.+` pattern beside it. - id: 'knip-logger-console', - file: 'knip.json', - find: ` "ignoreDependencies": [ - "@cordisjs/plugin-logger-console", - "@deepseek-ai/.+" - ] - }, - "packages/util/home": {`, - replace: ` "ignoreDependencies": [ - "@deepseek-ai/.+" - ] - }, - "packages/util/home": {`, - expect: 1, - }, - { - id: 'knip-bundle-base', - file: 'knip.json', - find: ` "packages/bundle/base": { - "ignoreDependencies": [ - "@deepseek-ai/.+", - "@cordisjs/.+" - ]`, - replace: ` "packages/bundle/base": { - "ignoreDependencies": [ - "@deepseek-ai/.+" - ]`, - expect: 1, - }, { // Rescoped packages are never fetched from a registry, so the exclusion is dead config. id: 'pnpm-release-age', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 1f65fed97e..cdb1e3c9db 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -283,7 +283,6 @@ function ciPrimaryGates(): Gate[] { docTypecheckScript: 'doc-typecheck:contracts-ready', }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), - pnpmScript('knip', 'knip'), // The prepared typecheck and build both drive Client tsc, while build also // repeats the Host contract pass. Wait for all three consumers so build // neither races tsbuildinfo nor replaces declarations while they are read. @@ -382,7 +381,6 @@ function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { docsBuildScript: 'docs:build:mpa', }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), - pnpmScript('knip', 'knip'), ] } @@ -608,7 +606,6 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds } return [ pnpmScript('rescope-vendor', 'rescope-vendor:check', { label: 'vendor rescope' }), - pnpmScript('knip', 'knip'), pnpmScript('publint', 'publint', artifactOptions), pnpmScript('constraints', 'constraints'), pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }), From 1a72ae202af15c32ce067efcb93824fb3b30587a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 13:57:58 +0800 Subject: [PATCH 02/48] refactor(session): migrate host state reads to projections --- ...ession-projection-mandatory-seam.i18n.yaml | 6 + ...08-07-session-projection-mandatory-seam.md | 32 ++ ...07-session-projection-mandatory-seam.zh.md | 32 ++ apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 3 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 + docs/architecture.zh.md | 2 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 74 ++-- docs/config-catalog.zh.md | 90 +++-- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 349 +++++++++--------- docs/module-graph.zh.md | 349 +++++++++--------- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 23 +- docs/persistence-catalog.zh.md | 23 +- docs/subsystems/approval.i18n.yaml | 4 +- docs/subsystems/approval.md | 6 +- docs/subsystems/approval.zh.md | 6 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 6 +- docs/subsystems/core.zh.md | 6 +- docs/subsystems/goal.i18n.yaml | 4 +- docs/subsystems/goal.md | 2 +- docs/subsystems/goal.zh.md | 2 +- docs/subsystems/permission-presets.i18n.yaml | 4 +- docs/subsystems/permission-presets.md | 12 +- docs/subsystems/permission-presets.zh.md | 12 +- docs/subsystems/plan.i18n.yaml | 4 +- docs/subsystems/plan.md | 6 +- docs/subsystems/plan.zh.md | 6 +- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 4 +- docs/subsystems/sandbox.zh.md | 4 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 6 +- docs/subsystems/session-projection.zh.md | 6 +- docs/subsystems/session-title.i18n.yaml | 4 +- docs/subsystems/session-title.md | 4 +- docs/subsystems/session-title.zh.md | 4 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 8 +- docs/subsystems/subagent.zh.md | 8 +- docs/subsystems/token-meter.i18n.yaml | 4 +- docs/subsystems/token-meter.md | 2 +- docs/subsystems/token-meter.zh.md | 2 +- .../headless-agent/tests/code-mode.e2e.ts | 3 + examples/headless-agent/tests/harness.ts | 2 + examples/jsonrpc-agent/cordis.yml | 4 + packages/acp/acp/package.json | 3 +- packages/acp/acp/tests/approval.spec.ts | 6 +- packages/acp/acp/tests/harness.ts | 5 + packages/api/remotes/tests/built-lib.e2e.ts | 4 + .../compaction/compaction-basic/package.json | 3 +- .../tests/compaction-basic.spec.ts | 10 + .../tests/compaction-loop-repro.spec.ts | 6 + .../tests/loader-composition.spec.ts | 7 + .../tests/manual-compaction.spec.ts | 3 + .../package.json | 3 +- .../tests/loader-composition.spec.ts | 6 +- .../tests/tool-result-pruner.spec.ts | 7 +- .../context/agent-instructions/package.json | 9 +- .../context/agent-instructions/src/index.ts | 7 + .../context/agent-instructions/src/render.ts | 2 +- .../context/agent-instructions/src/state.ts | 56 ++- .../tests/agent-instructions.e2e.ts | 2 + .../tests/agent-instructions.spec.ts | 109 +++--- .../context/session-reference/package.json | 4 +- .../tests/session-reference.spec.ts | 4 + packages/context/time-context/package.json | 9 +- packages/context/time-context/src/index.ts | 113 +++--- .../time-context/tests/time-context.spec.ts | 29 +- packages/context/time-context/tsconfig.json | 7 +- packages/context/tmux-context/package.json | 9 +- packages/context/tmux-context/src/index.ts | 58 ++- .../tmux-context/tests/tmux-context.spec.ts | 2 + packages/context/tmux-context/tsconfig.json | 7 +- packages/core/agent-loop/package.json | 13 +- packages/core/agent-loop/src/agent.ts | 4 +- packages/core/agent-loop/src/index.ts | 64 +++- .../agent-loop/tests/agent-initiator.spec.ts | 4 + packages/core/agent-loop/tests/agent.spec.ts | 2 + packages/core/agent-loop/tests/cancel.spec.ts | 3 + .../tests/config-session-id.spec.ts | 8 + .../tests/contract-regressions.spec.ts | 9 + .../agent-loop/tests/coverage-edges.spec.ts | 2 + .../agent-loop/tests/interception.spec.ts | 2 + packages/core/agent-loop/tests/loop.spec.ts | 4 + .../core/agent-loop/tests/properties.spec.ts | 2 + .../agent-loop/tests/request-cache.e2e.ts | 2 + .../agent-loop/tests/request-error.spec.ts | 2 + .../tests/request-reconstruction.spec.ts | 4 + packages/core/agent-loop/tests/resume.spec.ts | 11 + .../agent-loop/tests/scope-lifecycle.spec.ts | 2 + .../core/agent-loop/tests/settings.spec.ts | 2 + .../core/agent-loop/tests/tool-calls.spec.ts | 17 +- .../core/agent-loop/tests/tool-order.spec.ts | 2 + packages/core/agent-loop/tsconfig.json | 3 + packages/core/agent/package.json | 6 +- packages/core/agent/src/index.ts | 1 + packages/core/agent/src/projection.ts | 11 + packages/core/agent/src/types.ts | 14 + packages/core/agent/tsconfig.json | 3 + packages/core/tools/package.json | 3 +- packages/core/tools/tests/tools.spec.ts | 27 +- packages/examples/acp-demo/package.json | 3 +- .../examples/acp-demo/tests/acp-agent.spec.ts | 6 + .../examples/agent-spine-demo/package.json | 3 +- .../examples/agent-spine-demo/src/index.ts | 2 + .../agent-spine-demo/tests/agent-core.spec.ts | 8 + .../tests/multi-project-sandbox.e2e.ts | 2 + packages/experimental/team/package.json | 1 + .../team/tests/persistence.spec.ts | 2 + packages/experimental/team/tests/team.spec.ts | 4 + packages/experimental/tool-team/package.json | 1 + .../tool-team/tests/tool-team.spec.ts | 2 + .../extensions/tool-cordis/src/api-catalog.ts | 26 +- packages/fs/fs-sandbox/package.json | 3 +- .../fs/fs-sandbox/tests/fs-sandbox.spec.ts | 3 + packages/fs/tool-fs/package.json | 3 +- packages/fs/tool-fs/tests/harness.ts | 2 + packages/fs/tool-fs/tests/tools.spec.ts | 6 +- .../fs/tool-str-replace-editor/package.json | 3 +- .../tests/tools.spec.ts | 4 + packages/goal/command-goal/package.json | 3 +- .../command-goal/tests/command-goal.spec.ts | 2 + packages/goal/goal-round-driver/package.json | 3 +- .../tests/goal-round-driver.spec.ts | 3 + packages/goal/goal/src/index.ts | 24 +- packages/goal/goal/tests/goal.spec.ts | 9 + packages/goal/goal/tests/projection.spec.ts | 10 +- packages/goal/tool-goal/package.json | 7 +- packages/goal/tool-goal/src/authority.ts | 30 +- packages/goal/tool-goal/src/index.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 6 +- packages/goal/tool-goal/tsconfig.json | 3 + .../guard/repeat-tool-reminder/package.json | 3 +- .../tests/repeat-tool-reminder.spec.ts | 4 + packages/hooks/hooks-claude-code/package.json | 6 +- packages/hooks/hooks-claude-code/src/index.ts | 17 +- .../hooks-claude-code/tests/bridge.spec.ts | 8 +- .../hooks-claude-code/tests/coverage-cases.ts | 5 + packages/hooks/hooks-codex/package.json | 6 +- packages/hooks/hooks-codex/src/index.ts | 15 +- .../hooks/hooks-codex/tests/bridge.spec.ts | 8 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 4 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/package.json | 2 + packages/host/apiproxy/src/api-proxy.ts | 46 +-- packages/host/apiproxy/src/index.ts | 4 +- .../apiproxy/tests/api-proxy-approval.spec.ts | 8 + .../apiproxy/tests/api-proxy-rename.spec.ts | 2 + .../tests/api-proxy-subagents.spec.ts | 27 -- .../permission-presets/README.i18n.yaml | 4 +- .../interaction/permission-presets/README.md | 4 +- .../permission-presets/README.zh.md | 4 +- .../permission-presets/src/index.ts | 135 +++---- .../permission-presets/src/types.ts | 22 +- .../tests/permission-presets.spec.ts | 48 +-- .../tests/projection.spec.ts | 12 +- .../interaction/user-approval/package.json | 10 +- .../interaction/user-approval/src/index.ts | 102 +++-- .../user-approval/tests/approval.spec.ts | 84 ++++- .../interaction/user-approval/tsconfig.json | 3 + packages/llm/llm-retry/package.json | 9 +- packages/llm/llm-retry/src/index.ts | 54 ++- .../llm/llm-retry/tests/invariant.spec.ts | 25 ++ .../tests/loader-composition.spec.ts | 3 + packages/llm/llm-retry/tests/retry.spec.ts | 12 +- .../tests/transport-recovery.spec.ts | 2 + packages/llm/token-meter/README.i18n.yaml | 4 +- packages/llm/token-meter/README.md | 2 +- packages/llm/token-meter/README.zh.md | 2 +- packages/llm/token-meter/src/index.ts | 18 +- .../tests/token-usage-projection.spec.ts | 1 - packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 4 +- packages/plan/plan-mode/README.zh.md | 4 +- packages/plan/plan-mode/src/index.ts | 192 ++++------ packages/plan/plan-mode/src/types.ts | 24 +- .../plan/plan-mode/tests/integration.spec.ts | 12 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 33 +- .../plan/plan-mode/tests/projection.spec.ts | 61 ++- packages/preset/agent-presets/package.json | 1 + .../agent-presets/tests/invariant.spec.ts | 2 + .../preset/agent-presets/tests/mount.spec.ts | 4 + .../agent-presets/tests/settings.spec.ts | 2 + .../sandbox/sandbox-policy/README.i18n.yaml | 4 +- packages/sandbox/sandbox-policy/README.md | 4 +- packages/sandbox/sandbox-policy/README.zh.md | 4 +- packages/sandbox/sandbox-policy/package.json | 9 +- packages/sandbox/sandbox-policy/src/index.ts | 33 +- .../sandbox-policy/src/session-mode.ts | 24 +- .../sandbox-policy/tests/policy.spec.ts | 16 +- packages/sandbox/sandbox-policy/tsconfig.json | 3 + .../schedule/tests/jsonl-restart.spec.ts | 2 + .../schedule/schedule/tests/plugin.spec.ts | 2 + packages/sdk/server/package.json | 3 +- packages/sdk/server/tests/server.spec.ts | 2 + .../session-query-sqlite/package.json | 3 +- .../session-query-sqlite/src/index.ts | 2 +- .../tests/load-path.e2e.ts | 5 +- .../session-query-sqlite/tests/sqlite.spec.ts | 24 ++ .../tests/search-helpers.spec.ts | 3 + .../session-query/tests/session-query.spec.ts | 13 +- .../session-query/tests/tracing.spec.ts | 2 + .../tool-session-query/package.json | 8 +- .../tool-session-query/src/index.ts | 2 +- .../tool-session-query/src/operations.ts | 18 +- .../src/workspace-access.ts | 7 +- .../tests/sqlite-integration.spec.ts | 10 + .../tests/tool-session-query.spec.ts | 30 +- .../tool-session-query/tsconfig.json | 10 +- .../session-checkpoint-policy/package.json | 1 + .../tests/fixtures/crash-child.ts | 2 + .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 2 +- .../session/session-projection/README.zh.md | 2 +- .../session/session-projection/src/index.ts | 6 +- .../session/session-stats/README.i18n.yaml | 4 +- packages/session/session-stats/README.md | 2 +- packages/session/session-stats/README.zh.md | 2 +- .../package.json | 4 +- .../tests/provider.spec.ts | 4 + .../package.json | 4 +- .../tests/loader-composition.spec.ts | 3 + .../tests/provider.e2e.ts | 2 + .../tests/provider.spec.ts | 6 + .../session/session-title/README.i18n.yaml | 4 +- packages/session/session-title/README.md | 4 +- packages/session/session-title/README.zh.md | 4 +- packages/session/session-title/package.json | 7 +- packages/session/session-title/src/index.ts | 287 +++++++------- packages/session/session-title/src/types.ts | 84 ++++- .../session-title/tests/persistence.spec.ts | 5 + .../session-title/tests/projection.spec.ts | 33 +- .../session-title/tests/provider.spec.ts | 76 ++++ .../session-title/tests/rename.spec.ts | 7 + .../tests/service-contracts.spec.ts | 5 + .../session-title/tests/session-title.spec.ts | 10 + packages/session/session-title/tsconfig.json | 4 + packages/shell/bash-sandbox/package.json | 3 +- .../shell/bash-sandbox/tests/bwrap.e2e.ts | 2 + .../shell/bash-sandbox/tests/landlock.e2e.ts | 2 + .../tests/partial-landlock.spec.ts | 3 + .../shell/bash-sandbox/tests/sandbox.spec.ts | 2 + .../shell/bash-sandbox/tests/seatbelt.e2e.ts | 2 + packages/shell/pwsh-sandbox/package.json | 1 + .../shell/pwsh-sandbox/tests/sandbox.spec.ts | 2 + packages/shell/shell/README.i18n.yaml | 4 +- packages/shell/shell/README.md | 2 +- packages/shell/shell/README.zh.md | 2 +- .../shell/tool-bash-persistent/package.json | 3 +- .../tests/loader-composition.spec.ts | 3 + packages/shell/tool-bash/package.json | 3 +- .../shell/tool-bash/tests/integration.spec.ts | 3 + packages/shell/tool-bash/tests/tools.spec.ts | 6 +- packages/shell/tool-pwsh/package.json | 2 + packages/shell/tool-pwsh/tests/tools.spec.ts | 13 +- packages/skill/tool-skill/package.json | 9 +- packages/skill/tool-skill/src/index.ts | 58 ++- .../skill/tool-skill/tests/tool-skill.spec.ts | 4 + packages/subagent/subagent-acp/package.json | 3 +- .../subagent-acp/tests/subagent-acp.e2e.ts | 3 + .../subagent-acp/tests/subagent-acp.spec.ts | 13 + .../subagent-claude-code/package.json | 3 +- .../tests/real-deepseek.e2e.ts | 2 + .../tests/real-product.spec.ts | 2 + .../tests/subagent-claude-code.spec.ts | 6 + packages/subagent/subagent-codex/package.json | 3 +- .../subagent-codex/tests/real-deepseek.e2e.ts | 2 + .../subagent-codex/tests/real-product.spec.ts | 2 + .../tests/subagent-codex.spec.ts | 7 + .../subagent/subagent-dsh-sdk/package.json | 3 +- .../tests/subagent-dsh-sdk.spec.ts | 7 + .../subagent-fork-in-process/package.json | 3 +- .../tests/multi-subagent.spec.ts | 2 + .../tests/subagent-fork-in-process.spec.ts | 3 + .../subagent-in-process-driver/package.json | 3 +- .../tests/inheritance.spec.ts | 2 + .../tests/preset-inheritance.spec.ts | 2 + .../tests/structured.spec.ts | 2 + .../tests/subagent-in-process-driver.spec.ts | 2 + .../subagent-spawn-in-process/package.json | 3 +- .../tests/harness.ts | 2 + .../tests/subagent-spawn-in-process.spec.ts | 5 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 4 +- packages/subagent/subagent/README.zh.md | 4 +- packages/subagent/subagent/src/index.ts | 14 +- .../subagent/subagent/src/list-children.ts | 70 ++-- .../subagent/subagent/src/projection-types.ts | 8 +- packages/subagent/subagent/src/projection.ts | 64 ++-- .../tests/continuation-inheritance.spec.ts | 32 +- .../subagent/tests/continuation.spec.ts | 6 + .../subagent/subagent/tests/invariant.spec.ts | 4 + .../subagent/tests/list-children.spec.ts | 116 ++---- .../subagent/subagent/tests/service.spec.ts | 4 + .../tests/list-agents.spec.ts | 1 + .../tests/tool-subagent-control.spec.ts | 1 + .../tool-subagent-report/package.json | 3 +- .../tests/tool-subagent-report.spec.ts | 2 + packages/subagent/tool-subagent/package.json | 3 +- .../tests/scripted-provider.spec.ts | 3 + .../tool-subagent/tests/tool-subagent.spec.ts | 25 ++ packages/terminal/terminal-bash/package.json | 3 +- packages/terminal/terminal-bash/src/index.ts | 18 +- .../terminal-bash/tests/index.spec.ts | 17 +- .../terminal-bash/tests/local.spec.ts | 2 + packages/terminal/tool-terminal/package.json | 3 +- .../tests/loader-composition.spec.ts | 3 + packages/todo/tool-todo/README.i18n.yaml | 4 +- packages/todo/tool-todo/README.md | 2 +- packages/todo/tool-todo/README.zh.md | 2 +- packages/todo/tool-todo/src/index.ts | 40 +- .../todo/tool-todo/tests/integration.spec.ts | 2 + .../tests/loader-composition.spec.ts | 3 + .../todo/tool-todo/tests/tool-todo.spec.ts | 7 +- packages/workflow/tool-ralph/package.json | 3 +- .../tool-ralph/tests/integration.spec.ts | 3 + .../tool-ralph/tests/tool-ralph.spec.ts | 2 + packages/workflow/tool-workflow/package.json | 3 +- .../tool-workflow/tests/tool-workflow.spec.ts | 2 + .../workflow-worker-thread/package.json | 3 +- .../tests/built-worker.e2e.ts | 2 + .../tests/integration.spec.ts | 2 + .../tests/source-worker.compat.spec.ts | 2 + .../tests/workflow-worker-thread.e2e.ts | 2 + .../tests/workflow-worker-thread.spec.ts | 9 + pnpm-lock.yaml | 213 +++++++++++ scripts/gen-tool-catalog.ts | 2 +- scripts/type-equiv.manifest.json | 12 +- 337 files changed, 3186 insertions(+), 1736 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md create mode 100644 .agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.zh.md create mode 100644 packages/core/agent/src/projection.ts diff --git a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.i18n.yaml new file mode 100644 index 0000000000..78b8b2b683 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-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 .agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md +2026-08-07-session-projection-mandatory-seam.md: 6348ae9afdb1689cd4885e658651d1e5a1f44a4b +2026-08-07-session-projection-mandatory-seam.zh.md: 95f3489275f67fc18a030345e120e2cb8c88c191 diff --git a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md new file mode 100644 index 0000000000..6348ae9afd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md @@ -0,0 +1,32 @@ +# Agent Note: Session projections as a required seam + +Status: implemented + +English | [中文](2026-08-07-session-projection-mandatory-seam.zh.md) + +## Problem + +An optional projection registry lets contributors and readers activate without the service, silently dropping host state, client read models, or subagent catalog fields. Batch-only reads also materialize every client view when a consumer needs one host value. + +## Decision + +This decision builds on the split between host projection state and client views in [Session projection state and client views](2026-08-19-session-projection-state-and-client-views.md). + +`sessionProjections` is a required injection for every plugin that contributes or reads a projection unit. Official compositions mount the registry before those plugins. `ApiProxyService` follows the same rule; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics. + +The registry provides `stateOf(session, key)` for one typed host state and keeps `snapshot()` for batch carriers. Client views contain only consumed fields; host readers use `stateOf` for richer state. + +`onChanged` publishes client-visible value changes only. Unit registration and removal remain effect-scoped registry lifecycle; they do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set. + +## Alternatives considered + +- **Keep the registry optional.** This preserves more partial compositions but makes missing read models indistinguishable from valid absence. Rejected because official profiles already mount the registry and configuration errors should fail at load. +- **Use `snapshot()` for every read.** This keeps one method but computes unrelated wire views and encourages consumers to depend on batch transport data for host logic. Rejected in favor of typed single-key state reads. +- **Send full host values to clients.** This avoids separate view types but exposes provenance and policy knobs that no client consumes. Rejected in favor of explicit cropped views. +- **Broadcast registry additions and removals across Host and mux streams.** The streams have no shared ordering, so clients need tombstones, buffered frames, and baseline retries to reconcile them. Rejected because plugin-key churn does not justify a second synchronization protocol. + +## Consequences + +- Missing projection composition fails during plugin activation. +- Host consumers avoid repeated whole-registry snapshots and log scans. +- Wire payloads exclude host-only fields and per-key watermark wrappers; ordinary baselines communicate the active key set. diff --git a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.zh.md b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.zh.md new file mode 100644 index 0000000000..95f3489275 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 会话投影作为必需 seam + +Status: implemented + +[English](2026-08-07-session-projection-mandatory-seam.md) | 中文 + +## 问题 + +可选的投影注册表会让贡献方和读取方在缺少该服务时仍然激活,并静默丢失 host 状态、客户端读模型或 subagent 目录字段。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。 + +## 决策 + +本决策建立在[会话投影的 host 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.md)所定义的拆分之上。 + +每个贡献或读取投影单元的插件都把 `sessionProjections` 作为必需注入。正式组合在这些插件之前挂载注册表。`ApiProxyService` 遵循同一规则;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 + +注册表提供 `stateOf(session, key)` 来读取一个类型化 host 状态,并为批量 carrier 保留 `snapshot()`。客户端 view 只包含消费方使用的字段;host 读取方通过 `stateOf` 取得更丰富的状态。 + +`onChanged` 只发布客户端可见值的变化。单元注册和移除仍是绑定 effect 的注册表生命周期;它们不创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。 + +## 考虑过的替代方案 + +- **让注册表保持可选。** 这会保留更多不完整组合,但缺失读模型将无法与合法缺席区分。之所以否决,是因为正式 profile 已挂载注册表,且配置错误应在加载时失败。 +- **每次读取都使用 `snapshot()`。** 这只保留一个方法,但会计算无关 wire view,并鼓励消费方让 host 逻辑依赖批量传输数据。改用类型化单 key 状态读取。 +- **向客户端发送完整 host 值。** 这避免单独的 view 类型,但会暴露客户端不消费的来源信息和策略旋钮。改用显式裁剪的 view。 +- **跨 Host 和 mux stream 广播注册表新增和移除。** 两条 stream 没有共享顺序,客户端因此需要 tombstone、缓冲帧和基线重试来协调。插件 key 变化不值得引入第二套同步协议。 + +## 后果 + +- 缺少投影组合会在插件激活期间失败。 +- host 消费方避免重复的全注册表快照和日志扫描。 +- 协议负载排除 host 内部字段和逐 key 水位包装;普通基线会传达活跃 key 集。 diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index cdc4dbb6d3..ea9a85cc13 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,5 +1,4 @@ import { fileURLToPath } from 'node:url' -import { Context } from '@deepseek-ai/cordis' import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' @@ -20,7 +19,7 @@ try { const agentId = SessionId('dsh-badge-snapshot') const session = ctx.sessions.create(agentId, { meta: { cwd: process.cwd() } }) const agent: Agent = { - ctx: new Context(), + ctx, id: agentId, options: {}, session, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index f4d299db18..33c813ae3f 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 docs/architecture.md -architecture.md: 2a51992a4f99d1827259c0a2ca2daa309f0ddd95 -architecture.zh.md: d1629a088ed8d2fe61a7e20ab2ab1fd3709abd50 +architecture.md: 3ad9dec77f59af84594057e74602a1cc6c31dd18 +architecture.zh.md: e3e2128a71fd9b3abe915e9af2bfe1322f450661 diff --git a/docs/architecture.md b/docs/architecture.md index 2a51992a4f..3ad9dec77f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,6 +95,8 @@ The session log is the source of the context the model sees. `deriveMessages()` **Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend `SessionEventMap` and render from the log. +**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. Projection contributors and readers require this service, so an incomplete composition fails during activation. The agent loop registers shared `turnBoundary` state instead of making each consumer scan the log ([decision](../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md)). + ## Capability seams A **seam** is a swappable capability with three roles: a **Service Definition** declaring the interface, a **Service Provider** implementing it, and a **Consumer** using it, commonly a model-facing tool. A package may combine roles, but one role alone is not a seam; adding a capability means designing all three ([capability graph](capability-seams.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index d1629a088e..e3e2128a71 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -99,6 +99,8 @@ turn/end **模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。因此,新增一项模型可见输入就需要新增一个会话事件:扩展 `SessionEventMap` 并从日志渲染。 +**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。投影贡献方和读取方都要求该服务,因此不完整的组合会在激活时失败。agent loop 注册共享的 `turnBoundary` 状态,避免每个消费方各自扫描日志([决策](../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md))。 + ## 能力 seam 一个 **seam** 是一项可替换能力,包含三种角色:声明接口的 **Service Definition**、实现它的 **Service Provider**,以及使用它的 **Consumer**(通常是面向模型的工具)。一个包可以合并承担多个角色,但单一角色本身不是 seam;添加一项能力意味着把三者一并设计([能力图](capability-seams.md))。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 59263b95ec..0552465ffe 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 3f15ddbe310caf96a5c84b52628f9ba44f6e3637 -config-catalog.zh.md: 403db954334506e8023c6ed5f5dbad74eec58c6e +config-catalog.md: 1a3699d4c2f8d0611811ce8532892ca59b29d466 +config-catalog.zh.md: 6535c09adeb80d59639d47121dd8bd33e367f4bb diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3f15ddbe31..1a3699d4c2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -106,6 +106,8 @@ Source: [`packages/core/agent-default-model/src/index.ts:41`](../packages/core/a ## `@deepseek-ai/dsh-agent-instructions` +Requires: `sessionProjections` + ```ts config-catalog /** User-facing workspace instruction loader configuration. */ export interface Config { @@ -136,7 +138,7 @@ Source: [`packages/context/agent-instructions/src/config.ts:18`](../packages/con ## `@deepseek-ai/dsh-agent-loop` -Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` +Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -162,7 +164,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) -Source: [`packages/core/agent-loop/src/index.ts:255`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:314`](../packages/core/agent-loop/src/index.ts) @@ -292,7 +294,7 @@ export interface GoalConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) @@ -630,7 +632,7 @@ Source: [`packages/fs/fs-sandbox/src/index.ts:49`](../packages/fs/fs-sandbox/src ## `@deepseek-ai/dsh-goal` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** Deployment defaults for goal creation. */ @@ -640,7 +642,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:115`](../packages/goal/goal/src/index.ts) @@ -662,7 +664,7 @@ Source: [`packages/bundle/headless/src/index.ts:31`](../packages/bundle/headless ## `@deepseek-ai/dsh-hooks-claude-code` -Requires: `shell` +Requires: `shell` · `sessionProjections` ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -694,13 +696,13 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude-code/src/index.ts:45`](../packages/hooks/hooks-claude-code/src/index.ts) +Source: [`packages/hooks/hooks-claude-code/src/index.ts:46`](../packages/hooks/hooks-claude-code/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` -Requires: `shell` +Requires: `shell` · `sessionProjections` ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -721,13 +723,13 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry` +Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionProjections` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry` ```ts config-catalog /** Gateway plugin configuration. */ @@ -1162,14 +1164,14 @@ Source: [`packages/test-support/llm-replay/src/index.ts:776`](../packages/test-s ## `@deepseek-ai/dsh-llm-retry` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ export type Config = Readonly> ``` -Source: [`packages/llm/llm-retry/src/index.ts:24`](../packages/llm/llm-retry/src/index.ts) +Source: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) @@ -1306,7 +1308,7 @@ Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedb ## `@deepseek-ai/dsh-permission-presets` -Requires: `shell` · `approval` · `sessions` +Requires: `shell` · `approval` · `sessions` · `sessionProjections` ```ts config-catalog /** The {@link PermissionPresetService} config: preset table and composition default. */ @@ -1339,7 +1341,7 @@ export interface PresetSpec { Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) -Source: [`packages/interaction/permission-presets/src/index.ts:156`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:116`](../packages/interaction/permission-presets/src/index.ts) @@ -1369,7 +1371,7 @@ Source: [`packages/preset/persona/src/index.ts:34`](../packages/preset/persona/s ## `@deepseek-ai/dsh-plan-mode` -Requires: `tools` · `systemPrompt` +Requires: `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -1379,7 +1381,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:65`](../packages/plan/plan-mode/src/index.ts) @@ -1506,6 +1508,8 @@ Source: [`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/s ## `@deepseek-ai/dsh-sandbox-policy` +Requires: `sessionProjections` + ```ts config-catalog /** * Plugin config: the deployment's sandbox default. All optional — `Config` @@ -1527,7 +1531,7 @@ export interface Config { Depends on: [`SandboxMode`](subsystems/sandbox.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:67`](../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:68`](../packages/sandbox/sandbox-policy/src/index.ts) @@ -1664,7 +1668,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:42`](../package ## `@deepseek-ai/dsh-session-query-sqlite` -Requires: `sessions` +Requires: `sessions` · `sessionProjections` ```ts config-catalog /** Combined session-query configuration backed by SQLite full-text search. */ @@ -1776,7 +1780,7 @@ Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/ ## `@deepseek-ai/dsh-session-title` -Requires: `sessions` +Requires: `sessions` · `sessionProjections` ```ts config-catalog /** Required deterministic fallback and accepted-title limits. */ @@ -1790,7 +1794,7 @@ export interface Config { } ``` -Source: [`packages/session/session-title/src/index.ts:79`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:54`](../packages/session/session-title/src/index.ts) @@ -2303,7 +2307,7 @@ Source: [`packages/experimental/team/src/types.ts:125`](../packages/experimental ## `@deepseek-ai/dsh-terminal-bash` -Requires: `terminals` · `sandboxPolicy` · `subprocess` +Requires: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` ```ts config-catalog /** Public plugin configuration. */ @@ -2348,7 +2352,7 @@ Source: [`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal ## `@deepseek-ai/dsh-time-context` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -2360,13 +2364,13 @@ export interface Config { } ``` -Source: [`packages/context/time-context/src/index.ts:27`](../packages/context/time-context/src/index.ts) +Source: [`packages/context/time-context/src/index.ts:50`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-tmux-context` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -2376,12 +2380,14 @@ export interface Config { } ``` -Source: [`packages/context/tmux-context/src/index.ts:34`](../packages/context/tmux-context/src/index.ts) +Source: [`packages/context/tmux-context/src/index.ts:36`](../packages/context/tmux-context/src/index.ts) ## `@deepseek-ai/dsh-token-meter` +Requires: `sessionProjections` + ```ts config-catalog /** Token-meter plugin configuration; the fixed estimator has no settings. */ export type TokenMeterConfig = Record @@ -2488,7 +2494,7 @@ Source: [`packages/fs/tool-fs-search/src/index.ts:73`](../packages/fs/tool-fs-se ## `@deepseek-ai/dsh-tool-goal` -Requires: `agents` · `goals` · `tools` · `systemPrompt` +Requires: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Model policy and hard lower bounds for goal-state updates. */ @@ -2596,7 +2602,7 @@ Source: [`packages/workflow/tool-ralph/src/index.ts:23`](../packages/workflow/to ## `@deepseek-ai/dsh-tool-session-query` -Requires: `tools` · `systemPrompt` · `sessionQuery` +Requires: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ @@ -2614,7 +2620,7 @@ Source: [`packages/session-query/tool-session-query/src/index.ts:29`](../package ## `@deepseek-ai/dsh-tool-skill` -Requires: `agents` · `tools` · `skills` +Requires: `agents` · `tools` · `skills` · `sessionProjections` ```ts config-catalog /** Model-facing skill catalog configuration. */ @@ -2624,7 +2630,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-skill/src/index.ts:61`](../packages/skill/tool-skill/src/index.ts) +Source: [`packages/skill/tool-skill/src/index.ts:63`](../packages/skill/tool-skill/src/index.ts) @@ -2771,7 +2777,7 @@ Source: [`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal ## `@deepseek-ai/dsh-tool-todo` -Requires: `tools` +Requires: `tools` · `sessionProjections` ```ts config-catalog /** Model-facing todo tool configuration. */ @@ -2787,7 +2793,7 @@ export interface Config { } ``` -Source: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) +Source: [`packages/todo/tool-todo/src/index.ts:28`](../packages/todo/tool-todo/src/index.ts) @@ -2891,6 +2897,8 @@ Source: [`packages/typert/loader/src/index.ts:47`](../packages/typert/loader/src ## `@deepseek-ai/dsh-user-approval` +Requires: `sessionProjections` + ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { @@ -2916,7 +2924,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/interaction/user-approval/src/index.ts:177`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:175`](../packages/interaction/user-approval/src/index.ts) @@ -3158,7 +3166,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session-stats` — requires `sessionProjections` ([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) -- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) +- `@deepseek-ai/dsh-subagent` — requires `sessionProjections` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) - `@deepseek-ai/dsh-terminal` ([`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userQuestions` ([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 403db95433..6535c09ade 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -108,6 +108,8 @@ export interface Config { ## `@deepseek-ai/dsh-agent-instructions` +需要:`sessionProjections` + ```ts config-catalog /** User-facing workspace instruction loader configuration. */ export interface Config { @@ -138,7 +140,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-loop` -需要:`agents` · `sessions` · `llm` · `tools` · `systemPrompt` +需要:`agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -164,7 +166,7 @@ export interface Config { 依赖:[`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) -来源:[`packages/core/agent-loop/src/index.ts:255`](../packages/core/agent-loop/src/index.ts) +来源:[`packages/core/agent-loop/src/index.ts:314`](../packages/core/agent-loop/src/index.ts) @@ -294,7 +296,7 @@ export interface GoalConfig { 依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`JobsConfig`](#deepseek-aidsh-jobs-local) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillFileSystem`](../packages/skill/skill-filesystem/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/shell/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`toolJobs`](../packages/jobs/tool-jobs/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`workspaceContext`](../packages/context/agent-instructions/src/index.ts) -来源:[`packages/examples/agent-spine-demo/src/index.ts:92`](../packages/examples/agent-spine-demo/src/index.ts) +来源:[`packages/examples/agent-spine-demo/src/index.ts:93`](../packages/examples/agent-spine-demo/src/index.ts) @@ -420,7 +422,7 @@ export interface ConnectionConfig { ## `@deepseek-ai/dsh-client-hmr` -需要:`clientModuleHost` · `webServer` +需要:`clientModules` · `webServer` ```ts config-catalog /** Plugin config, validated by the same-named schemastery schema. */ @@ -632,7 +634,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-goal` -需要:`agents` +需要:`agents` · `sessionProjections` ```ts config-catalog /** Deployment defaults for goal creation. */ @@ -642,7 +644,7 @@ export interface Config { } ``` -来源:[`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index.ts) +来源:[`packages/goal/goal/src/index.ts:115`](../packages/goal/goal/src/index.ts) @@ -664,7 +666,7 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-claude-code` -需要:`bash` +需要:`shell` · `sessionProjections` ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -696,13 +698,13 @@ export interface Config { } ``` -来源:[`packages/hooks/hooks-claude-code/src/index.ts:45`](../packages/hooks/hooks-claude-code/src/index.ts) +来源:[`packages/hooks/hooks-claude-code/src/index.ts:46`](../packages/hooks/hooks-claude-code/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` -需要:`bash` +需要:`shell` · `sessionProjections` ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -723,13 +725,13 @@ export interface Config { } ``` -来源:[`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) +来源:[`packages/hooks/hooks-codex/src/index.ts:45`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-host-apiproxy` -需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` +需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionProjections` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry` ```ts config-catalog /** Gateway plugin configuration. */ @@ -1164,14 +1166,14 @@ export interface ReplayModelConfig { ## `@deepseek-ai/dsh-llm-retry` -需要:`agents` +需要:`agents` · `sessionProjections` ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ export type Config = Readonly> ``` -来源:[`packages/llm/llm-retry/src/index.ts:24`](../packages/llm/llm-retry/src/index.ts) +来源:[`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) @@ -1308,7 +1310,7 @@ export interface Config { ## `@deepseek-ai/dsh-permission-presets` -需要:`bash` · `approval` · `sessions` +需要:`shell` · `approval` · `sessions` · `sessionProjections` ```ts config-catalog /** The {@link PermissionPresetService} config: preset table and composition default. */ @@ -1341,7 +1343,7 @@ export interface PresetSpec { 依赖:[`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) -来源:[`packages/interaction/permission-presets/src/index.ts:156`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:116`](../packages/interaction/permission-presets/src/index.ts) @@ -1371,7 +1373,7 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` -需要:`tools` · `systemPrompt` +需要:`tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -1381,7 +1383,7 @@ export interface PlanModeConfig { } ``` -来源:[`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:65`](../packages/plan/plan-mode/src/index.ts) @@ -1508,6 +1510,8 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-policy` +需要:`sessionProjections` + ```ts config-catalog /** * Plugin config: the deployment's sandbox default. All optional — `Config` @@ -1529,7 +1533,7 @@ export interface Config { 依赖:[`SandboxMode`](subsystems/sandbox.md) -来源:[`packages/sandbox/sandbox-policy/src/index.ts:67`](../packages/sandbox/sandbox-policy/src/index.ts) +来源:[`packages/sandbox/sandbox-policy/src/index.ts:68`](../packages/sandbox/sandbox-policy/src/index.ts) @@ -1553,7 +1557,7 @@ export interface JsonRpcConfig { 依赖:`Readable`(`node:stream`)· `Writable`(`node:stream`) -来源:[`packages/sdk/server/src/index.ts:29`](../packages/sdk/server/src/index.ts) +来源:[`packages/sdk/server/src/index.ts:25`](../packages/sdk/server/src/index.ts) @@ -1666,7 +1670,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-query-sqlite` -需要:`sessions` +需要:`sessions` · `sessionProjections` ```ts config-catalog /** Combined session-query configuration backed by SQLite full-text search. */ @@ -1778,7 +1782,7 @@ export enum SessionTelemetryMode { ## `@deepseek-ai/dsh-session-title` -需要:`sessions` +需要:`sessions` · `sessionProjections` ```ts config-catalog /** Required deterministic fallback and accepted-title limits. */ @@ -1792,7 +1796,7 @@ export interface Config { } ``` -来源:[`packages/session/session-title/src/index.ts:79`](../packages/session/session-title/src/index.ts) +来源:[`packages/session/session-title/src/index.ts:54`](../packages/session/session-title/src/index.ts) @@ -2306,7 +2310,7 @@ export interface Config { ## `@deepseek-ai/dsh-terminal-bash` -需要:`pty` · `sandboxPolicy` · `subprocess` +需要:`terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` ```ts config-catalog /** Public plugin configuration. */ @@ -2351,7 +2355,7 @@ export interface Config { ## `@deepseek-ai/dsh-time-context` -需要:`agents` +需要:`agents` · `sessionProjections` ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -2363,13 +2367,13 @@ export interface Config { } ``` -来源:[`packages/context/time-context/src/index.ts:27`](../packages/context/time-context/src/index.ts) +来源:[`packages/context/time-context/src/index.ts:50`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-tmux-context` -需要:`agents` +需要:`agents` · `sessionProjections` ```ts config-catalog /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -2379,12 +2383,14 @@ export interface Config { } ``` -来源:[`packages/context/tmux-context/src/index.ts:34`](../packages/context/tmux-context/src/index.ts) +来源:[`packages/context/tmux-context/src/index.ts:36`](../packages/context/tmux-context/src/index.ts) ## `@deepseek-ai/dsh-token-meter` +需要:`sessionProjections` + ```ts config-catalog /** Token-meter plugin configuration; the fixed estimator has no settings. */ export type TokenMeterConfig = Record @@ -2396,7 +2402,7 @@ export type TokenMeterConfig = Record ## `@deepseek-ai/dsh-tool-bash` -需要:`tools` · `bash` · `systemPrompt` · `bashEnv` +需要:`tools` · `shell` · `systemPrompt` · `shellEnv` ```ts config-catalog /** Configuration for the bash tool. */ @@ -2412,7 +2418,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` -需要:`tools` · `pty` +需要:`tools` · `terminals` ```ts config-catalog /** Configuration for the persistent Bash tool. */ @@ -2491,7 +2497,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-goal` -需要:`agents` · `goals` · `tools` · `systemPrompt` +需要:`agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Model policy and hard lower bounds for goal-state updates. */ @@ -2507,7 +2513,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-jobs` -需要:`tools` · `tasks` · `systemPrompt` +需要:`tools` · `jobs` · `systemPrompt` ```ts config-catalog /** Configures bounded `job_output` waits and completion-notice delivery. */ @@ -2561,7 +2567,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` -需要:`tools` · `bash` · `systemPrompt` · `bashEnv` +需要:`tools` · `shell` · `systemPrompt` · `shellEnv` ```ts config-catalog /** Configuration for the pwsh tool. */ @@ -2577,7 +2583,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-ralph` -需要:`tools` · `workflows` · `subagents` · `systemPrompt` +需要:`tools` · `workflowEngine` · `subagents` · `systemPrompt` ```ts config-catalog /** Deployment policy for the fixed Ralph workflow. */ @@ -2599,7 +2605,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` -需要:`tools` · `systemPrompt` · `sessionQuery` +需要:`tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ @@ -2617,7 +2623,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-skill` -需要:`agents` · `tools` · `skills` +需要:`agents` · `tools` · `skills` · `sessionProjections` ```ts config-catalog /** Model-facing skill catalog configuration. */ @@ -2627,7 +2633,7 @@ export interface Config { } ``` -来源:[`packages/skill/tool-skill/src/index.ts:61`](../packages/skill/tool-skill/src/index.ts) +来源:[`packages/skill/tool-skill/src/index.ts:63`](../packages/skill/tool-skill/src/index.ts) @@ -2757,7 +2763,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-terminal` -需要:`pty` · `tools` · `systemPrompt` +需要:`terminals` · `tools` · `systemPrompt` ```ts config-catalog /** Model-facing terminal tool configuration. */ @@ -2775,7 +2781,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-todo` -需要:`tools` +需要:`tools` · `sessionProjections` ```ts config-catalog /** Model-facing todo tool configuration. */ @@ -2791,7 +2797,7 @@ export interface Config { } ``` -来源:[`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) +来源:[`packages/todo/tool-todo/src/index.ts:28`](../packages/todo/tool-todo/src/index.ts) @@ -2825,7 +2831,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-workflow` -需要:`tools` · `workflows` · `systemPrompt` +需要:`tools` · `workflowEngine` · `systemPrompt` ```ts config-catalog /** Config: the model-facing tool name plus result rendering caps. */ @@ -2895,6 +2901,8 @@ export interface Config { ## `@deepseek-ai/dsh-user-approval` +需要:`sessionProjections` + ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { @@ -2920,7 +2928,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -来源:[`packages/interaction/user-approval/src/index.ts:177`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:175`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index d933130f59..2e2f60e056 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: c906ee6329fac66e7391c266213dd150dd5b8e09 -event-producer-consumer.zh.md: 77bf401b7215bd263c0d84f04e0eabe6b28b7915 +event-producer-consumer.md: 699c84c9f84c486bdb090aec07cca63349e67f75 +event-producer-consumer.zh.md: 977d1909ec0370e81e90344ae0acef17dea5d842 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c906ee6329..699c84c9f8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:242`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`team`](../packages/experimental/team) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, [`team`](../packages/experimental/team) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | +| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:32`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 77bf401b72..977d1909ec 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -9,7 +9,7 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:242`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | @@ -23,7 +23,7 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`team`](../packages/experimental/team) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, [`team`](../packages/experimental/team) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | +| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:32`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1b0e8fad9c..d41746acf1 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 0bd5f80534ba65e0d483bd04228cdac321e082bc -module-graph.zh.md: 6239520b7d819eb14c2b859afa44a0c987545200 +module-graph.md: 8f9be27dbc41d9caf88f54ce9003dee598da8958 +module-graph.zh.md: 1054c845b4ecfb3cadda57572f09ead8ad4563fd diff --git a/docs/module-graph.md b/docs/module-graph.md index 0bd5f80534..8f9be27dbc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -436,12 +436,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_agent --> pkg_invariants - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt - pkg_agent --> pkg_typert_protocol pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_web_fetch_http --> pkg_invariants @@ -478,40 +472,19 @@ flowchart TD pkg_session_projection --> pkg_session pkg_acp_snapshot --> pkg_invariants pkg_acp_snapshot --> pkg_session - pkg_llm_retry --> pkg_agent - pkg_llm_retry --> pkg_brand - pkg_llm_retry --> pkg_invariants - pkg_llm_retry --> pkg_llm - pkg_llm_retry --> pkg_session - pkg_llm_retry --> pkg_timeout - pkg_agent_default_model --> pkg_agent - pkg_agent_default_model --> pkg_invariants - pkg_agent_default_model --> pkg_llm - pkg_agent_default_model --> pkg_settings - 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_goal --> pkg_session_projection - pkg_goal --> pkg_typert_protocol + pkg_agent --> pkg_invariants + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_session_projection + pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_typert_protocol pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox - pkg_web_search_deepseek --> pkg_agent - pkg_web_search_deepseek --> pkg_credentials - pkg_web_search_deepseek --> pkg_invariants - pkg_web_search_deepseek --> pkg_launch_environment - pkg_web_search_deepseek --> pkg_session - pkg_web_search_deepseek --> pkg_settings - pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_time_context --> pkg_agent - pkg_time_context --> pkg_invariants - pkg_time_context --> pkg_session pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants pkg_message_feedback --> pkg_llm @@ -519,45 +492,10 @@ flowchart TD pkg_message_feedback --> pkg_session_persistence pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol - pkg_commands --> pkg_agent - pkg_commands --> pkg_attachment - pkg_commands --> pkg_brand - pkg_commands --> pkg_invariants - pkg_commands --> pkg_llm - pkg_commands --> pkg_scope - pkg_commands --> pkg_session - pkg_commands --> pkg_typert_protocol - 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_questions --> pkg_agent - pkg_user_questions --> pkg_invariants - pkg_user_questions --> pkg_llm - pkg_jobs --> pkg_agent - pkg_jobs --> pkg_brand - pkg_jobs --> pkg_invariants - pkg_jobs --> pkg_session - pkg_agent_presets --> pkg_agent - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_home_paths - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings - pkg_agent_presets --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session - pkg_sandbox_policy --> pkg_agent - pkg_sandbox_policy --> pkg_invariants - pkg_sandbox_policy --> pkg_sandbox - pkg_sandbox_policy --> pkg_session - pkg_sandbox_policy --> pkg_system_prompt pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence @@ -573,18 +511,128 @@ flowchart TD pkg_session_stats --> pkg_llm pkg_session_stats --> pkg_session pkg_session_stats --> pkg_session_projection + pkg_shell --> pkg_invariants + pkg_shell --> pkg_sandbox + pkg_shell --> pkg_settings + pkg_shell --> pkg_subprocess + pkg_workspace --> pkg_brand + pkg_workspace --> pkg_invariants + pkg_workspace --> pkg_session + pkg_workspace --> pkg_session_persistence + pkg_workspace --> pkg_storage + pkg_workspace --> pkg_storage_domain + pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_brand + pkg_llm_retry --> pkg_invariants + pkg_llm_retry --> pkg_llm + pkg_llm_retry --> pkg_session + pkg_llm_retry --> pkg_session_projection + pkg_llm_retry --> pkg_timeout + pkg_agent_default_model --> pkg_agent + pkg_agent_default_model --> pkg_invariants + pkg_agent_default_model --> pkg_llm + pkg_agent_default_model --> pkg_settings + 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_goal --> pkg_session_projection + pkg_goal --> pkg_typert_protocol + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_observation_policy --> pkg_fs + pkg_fs_observation_policy --> pkg_invariants + pkg_skill_filesystem --> pkg_fs + pkg_skill_filesystem --> pkg_home_paths + pkg_skill_filesystem --> pkg_invariants + pkg_skill_filesystem --> pkg_skill + pkg_web_search_deepseek --> pkg_agent + pkg_web_search_deepseek --> pkg_credentials + pkg_web_search_deepseek --> pkg_invariants + pkg_web_search_deepseek --> pkg_launch_environment + pkg_web_search_deepseek --> pkg_session + pkg_web_search_deepseek --> pkg_settings + pkg_web_search_deepseek --> pkg_web + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session + pkg_hook_protocol --> pkg_shell + pkg_time_context --> pkg_agent + pkg_time_context --> pkg_invariants + pkg_time_context --> pkg_session + pkg_time_context --> pkg_session_projection + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session + pkg_tmux_context --> pkg_session_projection + pkg_tmux_context --> pkg_shell + pkg_fs_e2b --> pkg_e2b + pkg_fs_e2b --> pkg_fs + pkg_fs_e2b --> pkg_invariants + pkg_commands --> pkg_agent + pkg_commands --> pkg_attachment + pkg_commands --> pkg_brand + pkg_commands --> pkg_invariants + pkg_commands --> pkg_llm + pkg_commands --> pkg_scope + pkg_commands --> pkg_session + pkg_commands --> pkg_typert_protocol + 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_session_projection + pkg_user_approval --> pkg_system_prompt + pkg_user_questions --> pkg_agent + pkg_user_questions --> pkg_invariants + pkg_user_questions --> pkg_llm + pkg_jobs --> pkg_agent + pkg_jobs --> pkg_brand + pkg_jobs --> pkg_invariants + pkg_jobs --> pkg_session + pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_fs + pkg_lsp_stdio --> pkg_invariants + pkg_lsp_stdio --> pkg_llm + pkg_lsp_stdio --> pkg_lsp + pkg_lsp_stdio --> pkg_subprocess + pkg_lsp_stdio --> pkg_timeout + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_home_paths + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt + pkg_sandbox_policy --> pkg_agent + pkg_sandbox_policy --> pkg_invariants + pkg_sandbox_policy --> pkg_sandbox + pkg_sandbox_policy --> pkg_session + pkg_sandbox_policy --> pkg_session_projection + pkg_sandbox_policy --> pkg_system_prompt pkg_session_telemetry --> pkg_agent pkg_session_telemetry --> pkg_invariants pkg_session_telemetry --> pkg_session + pkg_session_title --> pkg_agent pkg_session_title --> pkg_brand pkg_session_title --> pkg_invariants pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection - pkg_shell --> pkg_invariants - pkg_shell --> pkg_sandbox - pkg_shell --> pkg_settings - pkg_shell --> pkg_subprocess + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_settings + pkg_bash_local --> pkg_shell + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_settings + pkg_pwsh_local --> pkg_shell + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand pkg_terminal --> pkg_invariants @@ -597,12 +645,6 @@ flowchart TD pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session - pkg_workspace --> pkg_brand - pkg_workspace --> pkg_invariants - pkg_workspace --> pkg_session - pkg_workspace --> pkg_session_persistence - pkg_workspace --> pkg_storage - pkg_workspace --> pkg_storage_domain pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -620,17 +662,11 @@ flowchart TD pkg_goal_round_driver --> pkg_invariants pkg_goal_round_driver --> pkg_llm pkg_goal_round_driver --> pkg_session - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_observation_policy --> pkg_fs - pkg_fs_observation_policy --> pkg_invariants - pkg_skill_filesystem --> pkg_fs - pkg_skill_filesystem --> pkg_home_paths - pkg_skill_filesystem --> pkg_invariants - pkg_skill_filesystem --> pkg_skill - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session - pkg_hook_protocol --> pkg_shell + 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_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -653,13 +689,6 @@ flowchart TD pkg_compaction --> pkg_invariants pkg_compaction --> pkg_llm pkg_compaction --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session - pkg_tmux_context --> pkg_shell - pkg_fs_e2b --> pkg_e2b - pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants @@ -679,33 +708,27 @@ flowchart TD pkg_jobs_local --> pkg_jobs pkg_jobs_local --> pkg_scope pkg_jobs_local --> pkg_timeout - pkg_lsp_stdio --> pkg_brand - pkg_lsp_stdio --> pkg_fs - pkg_lsp_stdio --> pkg_invariants - pkg_lsp_stdio --> pkg_llm - pkg_lsp_stdio --> pkg_lsp - pkg_lsp_stdio --> pkg_subprocess - pkg_lsp_stdio --> pkg_timeout pkg_session_title_llm --> pkg_invariants pkg_session_title_llm --> pkg_llm pkg_session_title_llm --> pkg_session pkg_session_title_llm --> pkg_session_title pkg_session_title_llm --> pkg_timeout - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_settings - pkg_bash_local --> pkg_shell - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_settings - pkg_pwsh_local --> pkg_shell - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_bash_sandbox --> pkg_shell + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_shell pkg_terminal_bash --> pkg_agent pkg_terminal_bash --> pkg_invariants pkg_terminal_bash --> pkg_sandbox pkg_terminal_bash --> pkg_sandbox_policy pkg_terminal_bash --> pkg_session + pkg_terminal_bash --> pkg_session_projection pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal pkg_token_meter --> pkg_compaction @@ -719,6 +742,7 @@ flowchart TD pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session pkg_agent_loop --> pkg_session_persistence + pkg_agent_loop --> pkg_session_projection pkg_agent_loop --> pkg_settings pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools @@ -729,13 +753,9 @@ flowchart TD pkg_tool_goal --> pkg_invariants pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session + pkg_tool_goal --> pkg_session_projection pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - 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_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants @@ -763,6 +783,7 @@ flowchart TD pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_llm + pkg_tool_skill --> pkg_session_projection pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools pkg_subagent --> pkg_agent @@ -811,14 +832,17 @@ flowchart TD pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence + pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence pkg_session_query_sqlite --> pkg_session_query + pkg_tool_session_query --> pkg_agent pkg_tool_session_query --> pkg_invariants pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session + pkg_tool_session_query --> pkg_session_projection pkg_tool_session_query --> pkg_session_query pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout @@ -832,6 +856,7 @@ flowchart TD pkg_agent_instructions --> pkg_invariants pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session + pkg_agent_instructions --> pkg_session_projection pkg_agent_instructions --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction @@ -907,16 +932,6 @@ flowchart TD pkg_session_title_first_prompt_llm --> pkg_session pkg_session_title_first_prompt_llm --> pkg_session_title pkg_session_title_first_prompt_llm --> pkg_session_title_llm - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_bash_sandbox --> pkg_shell - pkg_pwsh_sandbox --> pkg_invariants - pkg_pwsh_sandbox --> pkg_pwsh_local - pkg_pwsh_sandbox --> pkg_sandbox - pkg_pwsh_sandbox --> pkg_sandbox_policy - pkg_pwsh_sandbox --> pkg_shell pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_invariants pkg_shell_env --> pkg_session_persistence @@ -1001,6 +1016,7 @@ flowchart TD pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session pkg_hooks_claude_code --> pkg_session_persistence + pkg_hooks_claude_code --> pkg_session_projection pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools pkg_web_app --> pkg_invariants @@ -1026,6 +1042,7 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools + pkg_host_apiproxy --> pkg_agent_loop pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_cordis_host_runner pkg_host_apiproxy --> pkg_invariants @@ -1470,7 +1487,6 @@ flowchart TD | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | @@ -1483,72 +1499,75 @@ flowchart TD | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | +| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | +| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | +| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | +| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/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/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | -| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session-projection`](../packages/session/session-projection), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | @@ -1562,8 +1581,6 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | @@ -1577,12 +1594,12 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`team`](../packages/experimental/team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6239520b7d..1054c845b4 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -438,12 +438,6 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - pkg_agent --> pkg_invariants - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt - pkg_agent --> pkg_typert_protocol pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill pkg_web_fetch_http --> pkg_invariants @@ -480,40 +474,19 @@ flowchart TD pkg_session_projection --> pkg_session pkg_acp_snapshot --> pkg_invariants pkg_acp_snapshot --> pkg_session - pkg_llm_retry --> pkg_agent - pkg_llm_retry --> pkg_brand - pkg_llm_retry --> pkg_invariants - pkg_llm_retry --> pkg_llm - pkg_llm_retry --> pkg_session - pkg_llm_retry --> pkg_timeout - pkg_agent_default_model --> pkg_agent - pkg_agent_default_model --> pkg_invariants - pkg_agent_default_model --> pkg_llm - pkg_agent_default_model --> pkg_settings - 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_goal --> pkg_session_projection - pkg_goal --> pkg_typert_protocol + pkg_agent --> pkg_invariants + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_session_projection + pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_typert_protocol pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm pkg_fs --> pkg_sandbox - pkg_web_search_deepseek --> pkg_agent - pkg_web_search_deepseek --> pkg_credentials - pkg_web_search_deepseek --> pkg_invariants - pkg_web_search_deepseek --> pkg_launch_environment - pkg_web_search_deepseek --> pkg_session - pkg_web_search_deepseek --> pkg_settings - pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_time_context --> pkg_agent - pkg_time_context --> pkg_invariants - pkg_time_context --> pkg_session pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants pkg_message_feedback --> pkg_llm @@ -521,45 +494,10 @@ flowchart TD pkg_message_feedback --> pkg_session_persistence pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_typert_protocol - pkg_commands --> pkg_agent - pkg_commands --> pkg_attachment - pkg_commands --> pkg_brand - pkg_commands --> pkg_invariants - pkg_commands --> pkg_llm - pkg_commands --> pkg_scope - pkg_commands --> pkg_session - pkg_commands --> pkg_typert_protocol - 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_questions --> pkg_agent - pkg_user_questions --> pkg_invariants - pkg_user_questions --> pkg_llm - pkg_jobs --> pkg_agent - pkg_jobs --> pkg_brand - pkg_jobs --> pkg_invariants - pkg_jobs --> pkg_session - pkg_agent_presets --> pkg_agent - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_home_paths - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings - pkg_agent_presets --> pkg_system_prompt pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session - pkg_sandbox_policy --> pkg_agent - pkg_sandbox_policy --> pkg_invariants - pkg_sandbox_policy --> pkg_sandbox - pkg_sandbox_policy --> pkg_session - pkg_sandbox_policy --> pkg_system_prompt pkg_session_persistence_jsonl --> pkg_invariants pkg_session_persistence_jsonl --> pkg_session pkg_session_persistence_jsonl --> pkg_session_persistence @@ -575,18 +513,128 @@ flowchart TD pkg_session_stats --> pkg_llm pkg_session_stats --> pkg_session pkg_session_stats --> pkg_session_projection + pkg_shell --> pkg_invariants + pkg_shell --> pkg_sandbox + pkg_shell --> pkg_settings + pkg_shell --> pkg_subprocess + pkg_workspace --> pkg_brand + pkg_workspace --> pkg_invariants + pkg_workspace --> pkg_session + pkg_workspace --> pkg_session_persistence + pkg_workspace --> pkg_storage + pkg_workspace --> pkg_storage_domain + pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_brand + pkg_llm_retry --> pkg_invariants + pkg_llm_retry --> pkg_llm + pkg_llm_retry --> pkg_session + pkg_llm_retry --> pkg_session_projection + pkg_llm_retry --> pkg_timeout + pkg_agent_default_model --> pkg_agent + pkg_agent_default_model --> pkg_invariants + pkg_agent_default_model --> pkg_llm + pkg_agent_default_model --> pkg_settings + 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_goal --> pkg_session_projection + pkg_goal --> pkg_typert_protocol + pkg_fs_local --> pkg_fs + pkg_fs_local --> pkg_invariants + pkg_fs_observation_policy --> pkg_fs + pkg_fs_observation_policy --> pkg_invariants + pkg_skill_filesystem --> pkg_fs + pkg_skill_filesystem --> pkg_home_paths + pkg_skill_filesystem --> pkg_invariants + pkg_skill_filesystem --> pkg_skill + pkg_web_search_deepseek --> pkg_agent + pkg_web_search_deepseek --> pkg_credentials + pkg_web_search_deepseek --> pkg_invariants + pkg_web_search_deepseek --> pkg_launch_environment + pkg_web_search_deepseek --> pkg_session + pkg_web_search_deepseek --> pkg_settings + pkg_web_search_deepseek --> pkg_web + pkg_hook_protocol --> pkg_invariants + pkg_hook_protocol --> pkg_session + pkg_hook_protocol --> pkg_shell + pkg_time_context --> pkg_agent + pkg_time_context --> pkg_invariants + pkg_time_context --> pkg_session + pkg_time_context --> pkg_session_projection + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session + pkg_tmux_context --> pkg_session_projection + pkg_tmux_context --> pkg_shell + pkg_fs_e2b --> pkg_e2b + pkg_fs_e2b --> pkg_fs + pkg_fs_e2b --> pkg_invariants + pkg_commands --> pkg_agent + pkg_commands --> pkg_attachment + pkg_commands --> pkg_brand + pkg_commands --> pkg_invariants + pkg_commands --> pkg_llm + pkg_commands --> pkg_scope + pkg_commands --> pkg_session + pkg_commands --> pkg_typert_protocol + 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_session_projection + pkg_user_approval --> pkg_system_prompt + pkg_user_questions --> pkg_agent + pkg_user_questions --> pkg_invariants + pkg_user_questions --> pkg_llm + pkg_jobs --> pkg_agent + pkg_jobs --> pkg_brand + pkg_jobs --> pkg_invariants + pkg_jobs --> pkg_session + pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_fs + pkg_lsp_stdio --> pkg_invariants + pkg_lsp_stdio --> pkg_llm + pkg_lsp_stdio --> pkg_lsp + pkg_lsp_stdio --> pkg_subprocess + pkg_lsp_stdio --> pkg_timeout + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_home_paths + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt + pkg_sandbox_policy --> pkg_agent + pkg_sandbox_policy --> pkg_invariants + pkg_sandbox_policy --> pkg_sandbox + pkg_sandbox_policy --> pkg_session + pkg_sandbox_policy --> pkg_session_projection + pkg_sandbox_policy --> pkg_system_prompt pkg_session_telemetry --> pkg_agent pkg_session_telemetry --> pkg_invariants pkg_session_telemetry --> pkg_session + pkg_session_title --> pkg_agent pkg_session_title --> pkg_brand pkg_session_title --> pkg_invariants pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection - pkg_shell --> pkg_invariants - pkg_shell --> pkg_sandbox - pkg_shell --> pkg_settings - pkg_shell --> pkg_subprocess + pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_settings + pkg_bash_local --> pkg_shell + pkg_bash_local --> pkg_subprocess + pkg_bash_local --> pkg_timeout + pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_settings + pkg_pwsh_local --> pkg_shell + pkg_pwsh_local --> pkg_subprocess + pkg_pwsh_local --> pkg_timeout pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand pkg_terminal --> pkg_invariants @@ -599,12 +647,6 @@ flowchart TD pkg_workflow --> pkg_invariants pkg_workflow --> pkg_llm pkg_workflow --> pkg_session - pkg_workspace --> pkg_brand - pkg_workspace --> pkg_invariants - pkg_workspace --> pkg_session - pkg_workspace --> pkg_session_persistence - pkg_workspace --> pkg_storage - pkg_workspace --> pkg_storage_domain pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -622,17 +664,11 @@ flowchart TD pkg_goal_round_driver --> pkg_invariants pkg_goal_round_driver --> pkg_llm pkg_goal_round_driver --> pkg_session - pkg_fs_local --> pkg_fs - pkg_fs_local --> pkg_invariants - pkg_fs_observation_policy --> pkg_fs - pkg_fs_observation_policy --> pkg_invariants - pkg_skill_filesystem --> pkg_fs - pkg_skill_filesystem --> pkg_home_paths - pkg_skill_filesystem --> pkg_invariants - pkg_skill_filesystem --> pkg_skill - pkg_hook_protocol --> pkg_invariants - pkg_hook_protocol --> pkg_session - pkg_hook_protocol --> pkg_shell + 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_session_query --> pkg_brand pkg_session_query --> pkg_invariants pkg_session_query --> pkg_llm @@ -655,13 +691,6 @@ flowchart TD pkg_compaction --> pkg_invariants pkg_compaction --> pkg_llm pkg_compaction --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session - pkg_tmux_context --> pkg_shell - pkg_fs_e2b --> pkg_e2b - pkg_fs_e2b --> pkg_fs - pkg_fs_e2b --> pkg_invariants pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants @@ -681,33 +710,27 @@ flowchart TD pkg_jobs_local --> pkg_jobs pkg_jobs_local --> pkg_scope pkg_jobs_local --> pkg_timeout - pkg_lsp_stdio --> pkg_brand - pkg_lsp_stdio --> pkg_fs - pkg_lsp_stdio --> pkg_invariants - pkg_lsp_stdio --> pkg_llm - pkg_lsp_stdio --> pkg_lsp - pkg_lsp_stdio --> pkg_subprocess - pkg_lsp_stdio --> pkg_timeout pkg_session_title_llm --> pkg_invariants pkg_session_title_llm --> pkg_llm pkg_session_title_llm --> pkg_session pkg_session_title_llm --> pkg_session_title pkg_session_title_llm --> pkg_timeout - pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_settings - pkg_bash_local --> pkg_shell - pkg_bash_local --> pkg_subprocess - pkg_bash_local --> pkg_timeout - pkg_pwsh_local --> pkg_invariants - pkg_pwsh_local --> pkg_settings - pkg_pwsh_local --> pkg_shell - pkg_pwsh_local --> pkg_subprocess - pkg_pwsh_local --> pkg_timeout + pkg_bash_sandbox --> pkg_bash_local + pkg_bash_sandbox --> pkg_invariants + pkg_bash_sandbox --> pkg_sandbox + pkg_bash_sandbox --> pkg_sandbox_policy + pkg_bash_sandbox --> pkg_shell + pkg_pwsh_sandbox --> pkg_invariants + pkg_pwsh_sandbox --> pkg_pwsh_local + pkg_pwsh_sandbox --> pkg_sandbox + pkg_pwsh_sandbox --> pkg_sandbox_policy + pkg_pwsh_sandbox --> pkg_shell pkg_terminal_bash --> pkg_agent pkg_terminal_bash --> pkg_invariants pkg_terminal_bash --> pkg_sandbox pkg_terminal_bash --> pkg_sandbox_policy pkg_terminal_bash --> pkg_session + pkg_terminal_bash --> pkg_session_projection pkg_terminal_bash --> pkg_subprocess pkg_terminal_bash --> pkg_terminal pkg_token_meter --> pkg_compaction @@ -721,6 +744,7 @@ flowchart TD pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session pkg_agent_loop --> pkg_session_persistence + pkg_agent_loop --> pkg_session_projection pkg_agent_loop --> pkg_settings pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools @@ -731,13 +755,9 @@ flowchart TD pkg_tool_goal --> pkg_invariants pkg_tool_goal --> pkg_llm pkg_tool_goal --> pkg_session + pkg_tool_goal --> pkg_session_projection pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools - 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_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants @@ -765,6 +785,7 @@ flowchart TD pkg_tool_skill --> pkg_agent pkg_tool_skill --> pkg_invariants pkg_tool_skill --> pkg_llm + pkg_tool_skill --> pkg_session_projection pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools pkg_subagent --> pkg_agent @@ -813,14 +834,17 @@ flowchart TD pkg_hooks_codex --> pkg_llm pkg_hooks_codex --> pkg_session pkg_hooks_codex --> pkg_session_persistence + pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools pkg_session_query_sqlite --> pkg_invariants pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence pkg_session_query_sqlite --> pkg_session_query + pkg_tool_session_query --> pkg_agent pkg_tool_session_query --> pkg_invariants pkg_tool_session_query --> pkg_llm pkg_tool_session_query --> pkg_session + pkg_tool_session_query --> pkg_session_projection pkg_tool_session_query --> pkg_session_query pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout @@ -834,6 +858,7 @@ flowchart TD pkg_agent_instructions --> pkg_invariants pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session + pkg_agent_instructions --> pkg_session_projection pkg_agent_instructions --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction @@ -909,16 +934,6 @@ flowchart TD pkg_session_title_first_prompt_llm --> pkg_session pkg_session_title_first_prompt_llm --> pkg_session_title pkg_session_title_first_prompt_llm --> pkg_session_title_llm - pkg_bash_sandbox --> pkg_bash_local - pkg_bash_sandbox --> pkg_invariants - pkg_bash_sandbox --> pkg_sandbox - pkg_bash_sandbox --> pkg_sandbox_policy - pkg_bash_sandbox --> pkg_shell - pkg_pwsh_sandbox --> pkg_invariants - pkg_pwsh_sandbox --> pkg_pwsh_local - pkg_pwsh_sandbox --> pkg_sandbox - pkg_pwsh_sandbox --> pkg_sandbox_policy - pkg_pwsh_sandbox --> pkg_shell pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_invariants pkg_shell_env --> pkg_session_persistence @@ -1003,6 +1018,7 @@ flowchart TD pkg_hooks_claude_code --> pkg_llm pkg_hooks_claude_code --> pkg_session pkg_hooks_claude_code --> pkg_session_persistence + pkg_hooks_claude_code --> pkg_session_projection pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools pkg_web_app --> pkg_invariants @@ -1028,6 +1044,7 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools + pkg_host_apiproxy --> pkg_agent_loop pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_cordis_host_runner pkg_host_apiproxy --> pkg_invariants @@ -1472,7 +1489,6 @@ flowchart TD | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | @@ -1485,72 +1501,75 @@ flowchart TD | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | -| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | -| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | +| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`skill-filesystem`](../packages/skill/skill-filesystem) | `skill` | [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | +| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | +| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | +| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | -| [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | +| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | +| [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | -| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | +| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/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/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | -| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session-projection`](../packages/session/session-projection), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/runtime-diagnostics/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/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | -| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | +| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | -| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | @@ -1564,8 +1583,6 @@ flowchart TD | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | -| [`bash-sandbox`](../packages/shell/bash-sandbox) | `shell` | [`bash-local`](../packages/shell/bash-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | -| [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | @@ -1579,12 +1596,12 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`team`](../packages/experimental/team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index a45465099c..bee586ef93 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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 docs/persistence-catalog.md -persistence-catalog.md: c79c1bbf8f8fe9c3ed677f1cee160472b22a3584 -persistence-catalog.zh.md: 23e2c15810d97998b49cfee1d1e96204c30661f9 +persistence-catalog.md: db96226b648afecd65e74f50e14a82d036f7de74 +persistence-catalog.zh.md: 0a247b4c4c5bf7eedbf8b7b8120bac3828090bc6 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c79c1bbf8f..db96226b64 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -115,7 +115,7 @@ Sources: [`packages/core/session/src/types.ts:336`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:33`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -160,7 +160,7 @@ Source: [`packages/preset/agent-presets/src/session.ts:26`](../packages/preset/a Types: [CallId](subsystems/core.md) -Source: [`packages/interaction/user-approval/src/index.ts:44`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:73`](../packages/interaction/user-approval/src/index.ts) @@ -178,7 +178,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:44`](../packages/inter } ``` -Source: [`packages/interaction/user-approval/src/index.ts:55`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:84`](../packages/interaction/user-approval/src/index.ts) @@ -189,7 +189,8 @@ Source: [`packages/interaction/user-approval/src/index.ts:55`](../packages/inter * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override (folded by the approvalPolicy + * projection unit). * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ @@ -200,7 +201,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:55`](../packages/inter } ``` -Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:97`](../packages/interaction/user-approval/src/index.ts) ### `assistant/*` @@ -504,13 +505,13 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src /** * Records the selected preset as durable, log-only user intent. The knob * events follow in the same turn and control execution; this event stays - * out of the model transcript and lets {@link effectivePermissionPreset} + * out of the model transcript and lets the permission projection unit * preserve a selection when bundles match. */ 'permission/preset': { preset: string } ``` -Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:46`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` @@ -522,12 +523,12 @@ Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/ /** * Whether plan mode is in force from this point on: log-only, non-surface, * whole-value replace. The last `plan/mode` wins; a log with none folds to - * inactive through {@link foldPlanMode}. + * inactive through the projection unit's fold. */ 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:48`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -570,7 +571,7 @@ Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/ * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * override (folded by the sandboxMode projection unit). `source: 'delegation'` marks * an override seeded into a child; an absent source is a runtime switch. */ 'sandbox/mode': { @@ -648,7 +649,7 @@ Source: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/ Types: [SessionTitleEventData](subsystems/session-title.md) -Source: [`packages/session/session-title/src/index.ts:100`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:75`](../packages/session/session-title/src/index.ts) diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 23e2c15810..0a247b4c4c 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -117,7 +117,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:19`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:33`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -162,7 +162,7 @@ export type SessionEvent = { 类型:[CallId](subsystems/core.md) -来源:[`packages/interaction/user-approval/src/index.ts:44`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:73`](../packages/interaction/user-approval/src/index.ts) @@ -180,7 +180,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/user-approval/src/index.ts:55`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:84`](../packages/interaction/user-approval/src/index.ts) @@ -191,7 +191,8 @@ export type SessionEvent = { * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override (folded by the approvalPolicy + * projection unit). * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ @@ -202,7 +203,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/user-approval/src/index.ts:67`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:97`](../packages/interaction/user-approval/src/index.ts) ### `assistant/*` @@ -506,13 +507,13 @@ export type SessionEvent = { /** * Records the selected preset as durable, log-only user intent. The knob * events follow in the same turn and control execution; this event stays - * out of the model transcript and lets {@link effectivePermissionPreset} + * out of the model transcript and lets the permission projection unit * preserve a selection when bundles match. */ 'permission/preset': { preset: string } ``` -来源:[`packages/interaction/permission-presets/src/index.ts:50`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:46`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` @@ -524,12 +525,12 @@ export type SessionEvent = { /** * Whether plan mode is in force from this point on: log-only, non-surface, * whole-value replace. The last `plan/mode` wins; a log with none folds to - * inactive through {@link foldPlanMode}. + * inactive through the projection unit's fold. */ 'plan/mode': { active: boolean } ``` -来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:48`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -572,7 +573,7 @@ export type SessionEvent = { * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * override (folded by the sandboxMode projection unit). `source: 'delegation'` marks * an override seeded into a child; an absent source is a runtime switch. */ 'sandbox/mode': { @@ -650,7 +651,7 @@ export type SessionEvent = { 类型:[SessionTitleEventData](subsystems/session-title.md) -来源:[`packages/session/session-title/src/index.ts:100`](../packages/session/session-title/src/index.ts) +来源:[`packages/session/session-title/src/index.ts:75`](../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/approval.i18n.yaml b/docs/subsystems/approval.i18n.yaml index 864e850f91..08495f66b2 100644 --- a/docs/subsystems/approval.i18n.yaml +++ b/docs/subsystems/approval.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 docs/subsystems/approval.md -approval.md: 8e0ae22f5ed4c68f460ddfd929cf481d24015a27 -approval.zh.md: f4a6371f9ac962db4d0cbce41399a47e4e444dbd +approval.md: ee8ab1cfca1fba6868b5f4931bab3dd9118a7a9b +approval.zh.md: f84d6a1bcd44e565a5cce85fbeb2a1194a2acade diff --git a/docs/subsystems/approval.md b/docs/subsystems/approval.md index 8e0ae22f5e..ee8ab1cfca 100644 --- a/docs/subsystems/approval.md +++ b/docs/subsystems/approval.md @@ -132,7 +132,7 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void async request(req: ApprovalRequest): Promise /** - * Read the session override without applying the configured default. + * Read the projected session override without applying the configured default. * @param session - session whose log supplies the override. * @returns the last logged policy, or `undefined` without one. */ @@ -141,7 +141,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [Agent](core.md) · [Session](session.md) -Source: [`packages/interaction/user-approval/src/index.ts:192`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/interaction/user-approval/src/index.ts) @@ -166,5 +166,5 @@ Ask composed answerers for one decision. Return an outcome to claim the request Types: [Scoped](scope.md) -Source: [`packages/interaction/user-approval/src/index.ts:30`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:32`](../../packages/interaction/user-approval/src/index.ts) diff --git a/docs/subsystems/approval.zh.md b/docs/subsystems/approval.zh.md index f4a6371f9a..f84d6a1bcd 100644 --- a/docs/subsystems/approval.zh.md +++ b/docs/subsystems/approval.zh.md @@ -132,7 +132,7 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void async request(req: ApprovalRequest): Promise /** - * Read the session override without applying the configured default. + * Read the projected session override without applying the configured default. * @param session - session whose log supplies the override. * @returns the last logged policy, or `undefined` without one. */ @@ -141,7 +141,7 @@ overrideOf(session: Session): ApprovalPolicy | undefined Types: [Agent](core.md) · [Session](session.md) -Source: [`packages/interaction/user-approval/src/index.ts:192`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/interaction/user-approval/src/index.ts) @@ -166,5 +166,5 @@ Ask composed answerers for one decision. Return an outcome to claim the request Types: [Scoped](scope.md) -Source: [`packages/interaction/user-approval/src/index.ts:30`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:32`](../../packages/interaction/user-approval/src/index.ts) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 1265b726fe..7c38ce0010 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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 docs/subsystems/core.md -core.md: d14ad52e57572d5b0b110a0b16ff734e3499bdf5 -core.zh.md: 9ace28731f2532f571b66f2e7f6a3ea4a2c4e345 +core.md: 1f53a6f6831f4d969464c9ff83fd64a96b5cadd9 +core.zh.md: 083b88c136efec54bb45db3d736d7b131df9b048 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index d14ad52e57..1f53a6f683 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -377,7 +377,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise @@ -720,7 +720,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:256`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:257`](../../packages/core/agent/src/index.ts) @@ -1043,7 +1043,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void ``` -Source: [`packages/core/agent-loop/src/index.ts:183`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:242`](../../packages/core/agent-loop/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 9ace28731f..083b88c136 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -385,7 +385,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise @@ -728,7 +728,7 @@ list(): Agent[] roots(): Agent[] ``` -Source: [`packages/core/agent/src/index.ts:256`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:257`](../../packages/core/agent/src/index.ts) @@ -1051,7 +1051,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void ``` -Source: [`packages/core/agent-loop/src/index.ts:183`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:242`](../../packages/core/agent-loop/src/index.ts) diff --git a/docs/subsystems/goal.i18n.yaml b/docs/subsystems/goal.i18n.yaml index df731a5dba..3e48766d14 100644 --- a/docs/subsystems/goal.i18n.yaml +++ b/docs/subsystems/goal.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 docs/subsystems/goal.md -goal.md: 676b2f49d00681ac7f05b894cda0157ebba4cfcd -goal.zh.md: 1d46bc2c9ba1f3b35026ff132d323e46ccd3bc19 +goal.md: e5c8d77e7a897b097e7801f65024612d233319a9 +goal.zh.md: a4c8f26173a1ed4b991beaa02de7894258f50b6d diff --git a/docs/subsystems/goal.md b/docs/subsystems/goal.md index 676b2f49d0..e5c8d77e7a 100644 --- a/docs/subsystems/goal.md +++ b/docs/subsystems/goal.md @@ -247,7 +247,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView Types: [Agent](core.md) -Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:182`](../../packages/goal/goal/src/index.ts) diff --git a/docs/subsystems/goal.zh.md b/docs/subsystems/goal.zh.md index 1d46bc2c9b..a4c8f26173 100644 --- a/docs/subsystems/goal.zh.md +++ b/docs/subsystems/goal.zh.md @@ -247,7 +247,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView Types: [Agent](core.md) -Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:182`](../../packages/goal/goal/src/index.ts) diff --git a/docs/subsystems/permission-presets.i18n.yaml b/docs/subsystems/permission-presets.i18n.yaml index 9f048fd3be..07d7e5ea9f 100644 --- a/docs/subsystems/permission-presets.i18n.yaml +++ b/docs/subsystems/permission-presets.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 docs/subsystems/permission-presets.md -permission-presets.md: 9fc0fe8d0c347cc8a60b20df04542963aac03bb1 -permission-presets.zh.md: 3ae37c6c0cf3c4ef77be41968f446fb4f2199bd9 +permission-presets.md: 55f74f86478b96807678a9e0040a45fdd209503f +permission-presets.zh.md: 3e8877d28556eedca9218bd87f85e5bd0768b741 diff --git a/docs/subsystems/permission-presets.md b/docs/subsystems/permission-presets.md index 9fc0fe8d0c..55f74f8647 100644 --- a/docs/subsystems/permission-presets.md +++ b/docs/subsystems/permission-presets.md @@ -45,7 +45,7 @@ The service requires a confining `ctx.shell` executor and `ctx.approval`, and mi ## Current preset and the derived `custom` -`current(events)` derives the effective preset from the knobs, not from its own event alone: it folds the session's effective sandbox mode (falling back to the executor's configured mode) and effective approval policy (falling back to the approval service config, then `ask`), prefers a still-matching recorded selection, then the first matching table entry in declaration order, and otherwise returns `CUSTOM_PRESET` (`'custom'`). `custom` is derived-only: clients may display it as the current value, but it is never a switch target or an event payload. +`current(session)` derives the effective preset from the required `permissions` projection state, not from its own event alone. The unit folds the session's effective sandbox mode (falling back to the executor's configured mode), effective approval policy (falling back to the approval service config, then `ask`), and recorded selection. The service prefers a still-matching selection, then the first matching table entry in declaration order, and otherwise returns `CUSTOM_PRESET` (`'custom'`). `custom` is derived-only: clients may display it as the current value, but it is never a switch target or an event payload. `names` lists the switchable presets in table declaration order; `optionOf(name)` builds the option a client renders for a table key (label falls back to the key) or for `custom`, and throws for any other name. @@ -65,7 +65,7 @@ interface PresetOption { `set(session, name)` resolves the preset (unknown names throw), appends a log-only `permission/preset` event unless `name` is already the effective preset, then writes each knob through its own setter — `setSandboxMode` from [dsh-sandbox-policy](../../packages/sandbox/sandbox-policy) and `setApprovalPolicy` from [dsh-user-approval](../../packages/interaction/user-approval) — only when that knob's effective value changes. The selection event precedes the knob events in the same turn, and re-selecting the effective preset appends nothing at all. -`permission/preset` is durable, log-only user intent: it stays out of the model transcript (the knob events own the model-visible consequences through their consumers), and it exists so `current()` can preserve WHICH preset the user chose when two presets share a bundle; `effectivePermissionPreset(events)` folds the last one, and replay needs no catch-up state. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md); the method signatures are in the generated [service catalog](#ctxpermissionpresets--permissionpresetservice). +`permission/preset` is durable, log-only user intent: it stays out of the model transcript (the knob events own the model-visible consequences through their consumers), and it exists so `current()` can preserve WHICH preset the user chose when two presets share a bundle. The `permissions` projection folds that selection with both knob events; replay needs no catch-up state. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md); the method signatures are in the generated [service catalog](#ctxpermissionpresets--permissionpresetservice). @@ -86,10 +86,10 @@ Owns the deployment's permission presets and their write path. Requires a confin * Resolve the preset matching the effective knob values. A still-matching * last selection wins shared-bundle ties; otherwise the first table match * wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param events - the session's events in log order. + * @param session - the session whose knob state is read. * @returns the effective preset name, or `custom` when nothing matches. */ -current(events: readonly SessionEvent[]): string +current(session: Session): string /** * Build the whole select value for one folded knob state: every table @@ -125,7 +125,7 @@ optionOf(name: string): PresetOption set(session: Session, name: string): void ``` -Types: [Session](session.md) · [SessionEvent](session.md) +Types: [Session](session.md) -Source: [`packages/interaction/permission-presets/src/index.ts:175`](../../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:135`](../../packages/interaction/permission-presets/src/index.ts) diff --git a/docs/subsystems/permission-presets.zh.md b/docs/subsystems/permission-presets.zh.md index 3ae37c6c0c..3e8877d285 100644 --- a/docs/subsystems/permission-presets.zh.md +++ b/docs/subsystems/permission-presets.zh.md @@ -45,7 +45,7 @@ interface Config { ## 当前预设与派生的 `custom` -`current(events)` 从 knob 派生实际生效的预设,而不是只看自身事件:它折叠会话的生效沙箱模式(回退到执行器配置的模式)与生效审批策略(先回退到审批服务配置,再回退到 `ask`),优先取仍然匹配的已记录选择,其次取声明顺序中第一个匹配的表项,否则返回 `CUSTOM_PRESET`(`'custom'`)。`custom` 只是派生值:客户端可以把它显示为当前值,但它绝不是切换目标,也绝不出现在事件 payload 中。 +`current(session)` 从必需的 `permissions` 投影状态派生实际生效的预设,而不是只看自身事件。该单元折叠会话的生效沙箱模式(回退到执行器配置的模式)、生效审批策略(先回退到审批服务配置,再回退到 `ask`)和已记录选择。服务优先取仍然匹配的选择,其次取声明顺序中第一个匹配的表项,否则返回 `CUSTOM_PRESET`(`'custom'`)。`custom` 只是派生值:客户端可以把它显示为当前值,但它绝不是切换目标,也绝不出现在事件 payload 中。 `names` 按预设表声明顺序列出可切换的预设;`optionOf(name)` 为某个表键(label 回退为该键)或 `custom` 构建客户端渲染的选项,传入其他任何名称都会抛出异常。 @@ -65,7 +65,7 @@ interface PresetOption { `set(session, name)` 解析预设(未知名称抛出异常),在 `name` 尚不是生效预设时追加一条仅记日志的 `permission/preset` 事件,然后通过各旋钮自己的 setter([dsh-sandbox-policy](../../packages/sandbox/sandbox-policy) 的 `setSandboxMode` 与 [dsh-user-approval](../../packages/interaction/user-approval) 的 `setApprovalPolicy`)写入,且仅当该 knob的生效值发生变化时才写。同一轮次内,选择事件先于旋钮事件出现;重新选择当前生效的预设则什么都不追加。 -`permission/preset` 是持久、仅记日志的用户意图:它不进入模型 transcript(文本记录),模型可见的后果由 knob 事件经各自消费方承担;它存在是为了在两个预设共享同一个旋钮组合时,让 `current()` 仍能保住用户选择的究竟是哪一个预设;`effectivePermissionPreset(events)` 折叠最后一条,回放不需要任何追赶状态。完整事件声明见[持久化日志事件目录](../persistence-catalog.md);方法签名见生成的[服务目录](#ctxpermissionpresets--permissionpresetservice)。 +`permission/preset` 是持久、仅记日志的用户意图:它不进入模型 transcript(文本记录),模型可见的后果由 knob 事件经各自消费方承担;它存在是为了在两个预设共享同一个旋钮组合时,让 `current()` 仍能保住用户选择的究竟是哪一个预设。`permissions` 投影把该选择与两个 knob 事件一同折叠,回放不需要任何追赶状态。完整事件声明见[持久化日志事件目录](../persistence-catalog.md);方法签名见生成的[服务目录](#ctxpermissionpresets--permissionpresetservice)。 @@ -86,10 +86,10 @@ Owns the deployment's permission presets and their write path. Requires a confin * Resolve the preset matching the effective knob values. A still-matching * last selection wins shared-bundle ties; otherwise the first table match * wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param events - the session's events in log order. + * @param session - the session whose knob state is read. * @returns the effective preset name, or `custom` when nothing matches. */ -current(events: readonly SessionEvent[]): string +current(session: Session): string /** * Build the whole select value for one folded knob state: every table @@ -125,7 +125,7 @@ optionOf(name: string): PresetOption set(session: Session, name: string): void ``` -Types: [Session](session.md) · [SessionEvent](session.md) +Types: [Session](session.md) -Source: [`packages/interaction/permission-presets/src/index.ts:175`](../../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:135`](../../packages/interaction/permission-presets/src/index.ts) diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index 14823f0e11..34f012f697 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.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 docs/subsystems/plan.md -plan.md: 11abe27f2bfa0f2d32bd9d37600792ba915d63fb -plan.zh.md: 47fa160beb4651850a44b325edb6700b116855a4 +plan.md: e8e84bc92792198ca56f0abb7e208ff33f5c0ab6 +plan.zh.md: 1a848085342a2bceeda6515fde529bf2d7f5d5e1 diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index 11abe27f2b..e8e84bc927 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -8,7 +8,7 @@ Source: [`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/s ## Logged state and recovery -`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. `foldPlanMode(events, end?)` returns the last logged value in the prefix, or `false` when there is none — the state in force is always a pure fold of the session log, so resume, fork, and compaction recover it with no live mirror, and UIs observe committed flips through `session/event`. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). +`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. The required `plan` session-projection unit folds committed mode, command settlement, and the mode recorded at the latest request header. `ctx.planMode` reads that host state through `stateOf()`, while clients receive only `{ active, pending }`; resume, fork, and compaction recover both from the log. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). ## Pending selections and the pre-step append @@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeController` -`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. ```ts cordis-catalog /** @@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:199`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:169`](../../packages/plan/plan-mode/src/index.ts) diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index 47fa160beb..1a84808534 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -8,7 +8,7 @@ ## 已记录状态与恢复 -`plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.md):持久且可回放,绝不进入模型 transcript(文本记录)。`foldPlanMode(events, end?)` 返回前缀中最后一条已记录值,没有时返回 `false`:生效状态始终是会话日志的纯折叠,因此恢复、fork 与压缩(compaction)无需实时镜像即可将其复原,UI 通过 `session/event` 观察已提交的切换。完整事件声明见[持久化日志事件目录](../persistence-catalog.md)。 +`plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.md):持久且可回放,绝不进入模型 transcript(文本记录)。必需的 `plan` 会话投影单元折叠已提交模式、命令结算结果和最近一次请求头记录的模式。`ctx.planMode` 通过 `stateOf()` 读取该 host 状态,而客户端只接收 `{ active, pending }`;恢复、fork 与压缩(compaction)都能从日志恢复两者。完整事件声明见[持久化日志事件目录](../persistence-catalog.md)。 ## 待生效选择与 pre-step 追加 @@ -50,7 +50,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.planMode` — `PlanModeController` -`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror. +`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection's cropped `{ active, pending }` view. ```ts cordis-catalog /** @@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop Types: [Agent](core.md) -Source: [`packages/plan/plan-mode/src/index.ts:199`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:169`](../../packages/plan/plan-mode/src/index.ts) diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 400bf54df3..b46cd3a8be 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.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 docs/subsystems/sandbox.md -sandbox.md: 19f1807cf4fc7fa5e7ae0843daeee818a7e2bdde -sandbox.zh.md: 78ee0f162d1179abea723465bfd462ef9b9e255e +sandbox.md: bb35f3d655844e65fd30f1016fa31aec55ed8bc1 +sandbox.zh.md: bf8567944ec5789371002d376eb5181d4770381b diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 19f1807cf4..bb35f3d655 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -190,7 +190,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/san ### `ctx.sandboxPolicy` — `SandboxPolicyService` -The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. +The sandbox policy seam: the deployment default mode and workspace-write root, with per-session overrides folded from the log. ```ts cordis-catalog /** @@ -214,5 +214,5 @@ overrideOf(session: Session): SandboxMode | undefined Types: [Session](session.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:91`](../../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:108`](../../packages/sandbox/sandbox-policy/src/index.ts) diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 78ee0f162d..bf8567944e 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -190,7 +190,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts:158`](../../packages/sandbox/san ### `ctx.sandboxPolicy` — `SandboxPolicyService` -The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session's mode log and immutable cwd travel together to every enforcing capability. +The sandbox policy seam: the deployment default mode and workspace-write root, with per-session overrides folded from the log. ```ts cordis-catalog /** @@ -214,5 +214,5 @@ overrideOf(session: Session): SandboxMode | undefined Types: [Session](session.md) -Source: [`packages/sandbox/sandbox-policy/src/index.ts:91`](../../packages/sandbox/sandbox-policy/src/index.ts) +Source: [`packages/sandbox/sandbox-policy/src/index.ts:108`](../../packages/sandbox/sandbox-policy/src/index.ts) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index cb6375dd42..de2d3209b4 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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 docs/subsystems/session-projection.md -session-projection.md: c0e75ddad59cd764025269fef1f79f07472b74fe -session-projection.zh.md: 24c96b6055a1f89ee256253c3005e9ca100a4f90 +session-projection.md: 3a354c081e38d23eb5ba337f3d4973788f1fe124 +session-projection.zh.md: 378b22faff90d15b3e34dc31112597a64a3a7b44 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index c0e75ddad5..3a354c081e 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -2,7 +2,7 @@ English | [中文](session-projection.zh.md) -The session-projection seam — a [capability seam](../capability-seams.md) through which domain host plugins serve whole current values of log-derived per-session state to client carriers: the Service Definition and registry ([dsh-session-projection](../../packages/session/session-projection), `ctx.sessionProjections`), domain contributors (each registering one pure unit), and carriers ([dsh-host-apiproxy](../../packages/host/apiproxy)'s history tail page and `session/projection` push frame). It is one optional capability, not part of the agent-loop spine. The framework drives, the domain computes: the registry subscribes to `session/event` once and folds every committed event through every unit; domains hold no subscriptions and clients never fold domain events — they receive finished values. Design authority: the [session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md); drive/cache/feed contracts: the [package README](../../packages/session/session-projection/README.md). +The session-projection [capability seam](../capability-seams.md) serves log-derived per-session state to host consumers and client carriers. Its registry ([dsh-session-projection](../../packages/session/session-projection), `ctx.sessionProjections`) drives pure domain units; [dsh-host-apiproxy](../../packages/host/apiproxy) carries selected values on history/list baselines and `session/projection` frames. Plugins that contribute or read units require the registry. Design authority: the [session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md); drive/cache/feed contracts: the [package README](../../packages/session/session-projection/README.md). Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) @@ -100,7 +100,7 @@ type ProjectionChangeListener = ( ## The registry: `ctx.sessionProjections` -`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence. Unit contributors and host readers require the registry, so an incomplete composition fails during activation ([decision](../../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md)). Registrants of the same compatible definition share the unit until the last registration is disposed. @@ -165,7 +165,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Unit contributors and host readers require this service, so incomplete compositions fail during activation. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 24c96b6055..378b22faff 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -2,7 +2,7 @@ [English](session-projection.md) | 中文 -会话投影 seam 是一项[能力 seam](../capability-seams.md):领域 host 插件经由它向客户端载体供给按会话的日志派生状态的当前全量值;三方分别是 Service Definition 与注册表([dsh-session-projection](../../packages/session/session-projection),`ctx.sessionProjections`)、领域贡献方(每个领域注册一个纯单元)与载体([dsh-host-apiproxy](../../packages/host/apiproxy) 的历史尾页与 `session/projection` 推送帧)。它是一项可选能力,不属于 agent loop(智能体循环)主干。框架负责驱动,领域负责计算:注册表只订阅一次 `session/event`,并把每个已提交事件折叠进每个单元;领域不持有任何订阅,客户端也从不折叠领域事件——它们收到的是成品值。设计权威:[session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md);驱动、缓存与变更流约定:[包 README](../../packages/session/session-projection/README.md)。 +会话投影[能力 seam](../capability-seams.md)向 host 消费方和客户端载体提供按会话的日志派生状态。其注册表([dsh-session-projection](../../packages/session/session-projection),`ctx.sessionProjections`)驱动纯领域单元;[dsh-host-apiproxy](../../packages/host/apiproxy)在历史/列表基线和 `session/projection` 帧中承载选定值。贡献或读取单元的插件要求该注册表。设计权威:[session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md);驱动、缓存与变更流约定:[包 README](../../packages/session/session-projection/README.md)。 源码:[`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) @@ -100,7 +100,7 @@ type ProjectionChangeListener = ( ## 注册表:`ctx.sessionProjections` -`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 +`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失。单元贡献方与 host 读取方要求该注册表,因此不完整的组合会在激活时失败([决策](../../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md))。相同且兼容定义的多个注册者共享该单元,直到最后一项注册被 dispose。 @@ -165,7 +165,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Unit contributors and host readers require this service, so incomplete compositions fail during activation. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/docs/subsystems/session-title.i18n.yaml b/docs/subsystems/session-title.i18n.yaml index 2dbeaad759..721bbb0459 100644 --- a/docs/subsystems/session-title.i18n.yaml +++ b/docs/subsystems/session-title.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 docs/subsystems/session-title.md -session-title.md: 6952cd7289861e1f83e76ef7ae9a115a404ee671 -session-title.zh.md: 37442e258325e23fe6d3b288dd8c680efd2d0ca4 +session-title.md: d872086500cf477cfa88f8eb756fe9b7be5fc571 +session-title.zh.md: 01aa62a24320e9c9329641603529c1ced9da4360 diff --git a/docs/subsystems/session-title.md b/docs/subsystems/session-title.md index 6952cd7289..d872086500 100644 --- a/docs/subsystems/session-title.md +++ b/docs/subsystems/session-title.md @@ -8,7 +8,7 @@ Sources: [`packages/session/session-title/src/index.ts`](../../packages/session/ ## Durable title state -`SessionTitleProviderId` is recorded for provider-produced revisions. `SessionTitleEventData` lists the exact human-message seqs used for the title, while `SessionTitleSnapshot` adds the durable event envelope facts selected by `foldSessionTitle()`. +`SessionTitleProviderId` is recorded for provider-produced revisions. `SessionTitleEventData` lists the exact human-message seqs used for the title, while `SessionTitleSnapshot` adds the durable event envelope facts retained in the `title` projection state. `ctx.sessionTitle.get()` reads that state through `stateOf()`; the client view remains only the title string or `null`. `foldSessionTitle()` provides the same selection for detached logs. ```ts type-equiv /** Identifies one session-title provider registration. */ @@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](session.md) -Source: [`packages/session/session-title/src/index.ts:261`](../../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:282`](../../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/session-title.zh.md b/docs/subsystems/session-title.zh.md index 37442e2583..01aa62a243 100644 --- a/docs/subsystems/session-title.zh.md +++ b/docs/subsystems/session-title.zh.md @@ -8,7 +8,7 @@ ## 持久标题状态 -提供方生成修订时会记录 `SessionTitleProviderId`。`SessionTitleEventData` 列出生成标题时使用的精确人类消息 seq,`SessionTitleSnapshot` 则加入 `foldSessionTitle()` 选出的持久事件封装信息。 +提供方生成修订时会记录 `SessionTitleProviderId`。`SessionTitleEventData` 列出生成标题时使用的精确人类消息 seq,`SessionTitleSnapshot` 则加入 `title` 投影状态保留的持久事件封装信息。`ctx.sessionTitle.get()` 通过 `stateOf()` 读取该状态;客户端视图仍只包含标题字符串或 `null`。`foldSessionTitle()` 为脱离服务的日志提供相同选择。 ```ts type-equiv /** Identifies one session-title provider registration. */ @@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](session.md) -Source: [`packages/session/session-title/src/index.ts:261`](../../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:282`](../../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 42411e4786..f89d4dc223 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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 docs/subsystems/subagent.md -subagent.md: fee5f95d4a8810959e452653ecf9d7bcf36b7387 -subagent.zh.md: 38136f27172c071af95bf3b3cce7986af499368f +subagent.md: e998c2fbea2d2e2a89798275466a4c00a205e895 +subagent.zh.md: 0eaf927552b6e19ab4f7b58a47c2409da44498d3 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index fee5f95d4a..e998c2fbea 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -286,7 +286,7 @@ A local one-shot provider appends the descriptor inside the child's initial turn ## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries -`SubagentRuntime.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query service, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `persistence.inspect()` reading folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished (`unsupported` remains in the type but is never produced); a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` and `ctx.agents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and refines status through the live Agent registry into its own `running`/`idle`/`ready` vocabulary, whose `ready` names a storage-only child as resumable rather than terminal. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). +`SubagentRuntime.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions.list()` and optional `ctx.sessionPersistence.list()` — no query service, and no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: `stateOf()` for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `persistence.inspect()` reading folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished (`unsupported` remains in the type but is never produced); a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` requires the runtime's projection registry and throws `SubagentError` with code `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is absent, checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` and `ctx.agents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and refines status through the live Agent registry into its own `running`/`idle`/`ready` vocabulary, whose `ready` names a storage-only child as resumable rather than terminal. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. The read-path rationale lives in [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md). `SubagentRuntime.listDescendants(rootSessionId)` applies the same live-preferred corpus and projection-backed interpretation to the root's complete descendant tree in stable pre-order. Ordinary sessions and one-shot children remain traversal nodes, so continuable descendants below them are discovered; only `origin: 'subagent'` candidates produce rows. Each returned child or diagnostic adds its position from the enumerated durable header, while a cold inspection revalidates that complete lifecycle before serving identity: @@ -591,7 +591,7 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P * row when the optional cache serves an own-suffix identity (its `seq` * gate proves the value postdates the fork seed, where a child's own * descriptor is immutable once appended), else one persistence inspection - * folded through the registry. The + * folded through the same unit. The * projection fold is the single classification authority; per-child * diagnostics relay a fold that served no identity or a failed inspection, * never a list-time descriptor parse. Absent persistence, enumeration is @@ -606,8 +606,8 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P * @param signal - caller-owned cancellation forwarded to persistence reads * and observed around every read await. * @returns children and per-child diagnostics ordered by `createdAt`, then id. - * @throws {@link SubagentError} when the projection registry or the session - * store is not mounted, or the caller cancels the listing. + * @throws {@link SubagentError} when the session store is not mounted, or + * the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 38136f2717..0eaf927552 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -286,7 +286,7 @@ interface ContinuableCreateSpec { ## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目 -`SubagentRuntime.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询服务,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 约定负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `persistence.inspect()` 读取上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分(`unsupported` 仍保留在类型中但从不产出);运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时要求 `ctx.subagents` 与 `ctx.agents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并通过在线 Agent 注册表将状态细化为自己的 `running`/`idle`/`ready` 词汇,其中 `ready` 把仅存于存储的 child 命名为可恢复而非终态。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 +`SubagentRuntime.listChildren(parentSessionId)` 从 `ctx.sessions.list()` 与可选 `ctx.sessionPersistence.list()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不经查询服务,也不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 约定负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 通过 `stateOf()` 供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `persistence.inspect()` 读取上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分(`unsupported` 仍保留在类型中但从不产出);运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。`listChildren()` 要求 runtime 的投影注册表;缺少会话存储时抛出携带错误码 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 的 `SubagentError`,并在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时要求 `ctx.subagents` 与 `ctx.agents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并通过在线 Agent 注册表将状态细化为自己的 `running`/`idle`/`ready` 词汇,其中 `ready` 把仅存于存储的 child 命名为可恢复而非终态。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。读路径的设计理由见[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)。 `SubagentRuntime.listDescendants(rootSessionId)` 将同一份实时优先语料与基于投影的解释应用到根的完整后代树,并按稳定 pre-order 输出。普通会话和一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现;只有 `origin: 'subagent'` 的候选会生成条目。每个返回的 child 或 diagnostic 都从枚举所得的持久 header 附加树位置;冷检查在提供身份前还会重新校验完整生命周期: @@ -593,7 +593,7 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P * row when the optional cache serves an own-suffix identity (its `seq` * gate proves the value postdates the fork seed, where a child's own * descriptor is immutable once appended), else one persistence inspection - * folded through the registry. The + * folded through the same unit. The * projection fold is the single classification authority; per-child * diagnostics relay a fold that served no identity or a failed inspection, * never a list-time descriptor parse. Absent persistence, enumeration is @@ -608,8 +608,8 @@ async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): P * @param signal - caller-owned cancellation forwarded to persistence reads * and observed around every read await. * @returns children and per-child diagnostics ordered by `createdAt`, then id. - * @throws {@link SubagentError} when the projection registry or the session - * store is not mounted, or the caller cancels the listing. + * @throws {@link SubagentError} when the session store is not mounted, or + * the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise diff --git a/docs/subsystems/token-meter.i18n.yaml b/docs/subsystems/token-meter.i18n.yaml index 57ffd70d04..e14c696d4d 100644 --- a/docs/subsystems/token-meter.i18n.yaml +++ b/docs/subsystems/token-meter.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 docs/subsystems/token-meter.md -token-meter.md: a1a096b800d895cabe9e05b751e23f06cea67d67 -token-meter.zh.md: 16e13e3d168c3cd67bdf12903045dd9b4056402f +token-meter.md: 180da591e6e123e07c5249cd88d9e59fd3e448b3 +token-meter.zh.md: b8b82fcefca836796cd3d987af863afe80b0700b diff --git a/docs/subsystems/token-meter.md b/docs/subsystems/token-meter.md index a1a096b800..180da591e6 100644 --- a/docs/subsystems/token-meter.md +++ b/docs/subsystems/token-meter.md @@ -86,5 +86,5 @@ estimateMessage(message: Message): number Types: [EpochHeader](session.md) · [Message](llm-streaming.md) · [Session](session.md) -Source: [`packages/llm/token-meter/src/index.ts:74`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:78`](../../packages/llm/token-meter/src/index.ts) diff --git a/docs/subsystems/token-meter.zh.md b/docs/subsystems/token-meter.zh.md index 16e13e3d16..b8b82fcefc 100644 --- a/docs/subsystems/token-meter.zh.md +++ b/docs/subsystems/token-meter.zh.md @@ -86,5 +86,5 @@ estimateMessage(message: Message): number Types: [EpochHeader](session.md) · [Message](llm-streaming.md) · [Session](session.md) -Source: [`packages/llm/token-meter/src/index.ts:74`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:78`](../../packages/llm/token-meter/src/index.ts) diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index cca7641575..a86d2cf2c0 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -12,6 +12,7 @@ import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -53,6 +54,7 @@ async function codeModeHarness(cwd: string): Promise { const harness = new Context() await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) + await harness.plugin(SessionProjectionRegistry) await harness.plugin(SystemPrompt, { persona: PERSONA }) await harness.plugin(ToolRuntime, { mode: 'code' }) await harness.plugin(AgentRegistry) @@ -70,6 +72,7 @@ async function workspaceCodeModeHarness(): Promise { const harness = new Context() await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) + await harness.plugin(SessionProjectionRegistry) await harness.plugin(SystemPrompt, { persona: PERSONA }) await harness.plugin(ToolRuntime, { mode: 'code' }) await harness.plugin(AgentRegistry) diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index d20637eede..495b7ebbd2 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -2,6 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' @@ -55,6 +56,7 @@ export interface CodingHarnessOptions { export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' }, }) diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 2f7ea46ddf..09b8987503 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -46,6 +46,10 @@ - id: session-checkpoints name: '@deepseek-ai/dsh-session-checkpoint-policy' +# Required by plugins that register or read session projection state below. +- id: session-projection + name: '@deepseek-ai/dsh-session-projection' + - id: subagent name: '@deepseek-ai/dsh-subagent' diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index b099fd90f3..8df0aa7896 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index ea1ec994a4..f4a0c9b640 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -61,7 +61,11 @@ describe('ACP machine permission policy', () => { harness = await makeBridgeHarness() const request = await ownedRequest() const foreign = { - session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) }, + session: { + id: request.agent.session.id, + events: [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }], + append: () => ({}), + }, } as unknown as Agent await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') })) .resolves.toBe('unavailable') diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index ce6e93794f..9f579e8b64 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -17,6 +17,7 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, St import { type GenerateOptions, LlmAdapter, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as AcpPlugin from '../src/index.ts' import type { AcpConfig } from '../src/index.ts' @@ -184,6 +185,10 @@ export async function makeBridgeHarness(options: { const adapter = new MockAdapter(options.script ?? [], options.imageCapable === true) const ctx = new Context() await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } }) + // The agent loop and the composed approval/permission services declare + // sessionProjections a required injection: mount the registry (and with it + // the loop's turnBoundary unit) before the loop activates. + await ctx.plugin(SessionProjectionRegistry) if (options.attachments !== false) await ctx.plugin(MemoryAttachmentStore) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 232cf9b2f6..018e11211c 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -26,6 +26,7 @@ const requiredArtifacts = [ 'packages/api/gateway/lib/index.js', 'packages/typert/registry/lib/client.js', 'packages/typert/registry/lib/index.js', + 'packages/session/session-projection/lib/index.js', ].every(path => existsSync(artifact(path))) describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { @@ -42,6 +43,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { registryHost: 'packages/typert/registry/lib/index.js', remotesClient: 'packages/api/remotes/lib/client.js', session: 'packages/core/session/lib/index.js', + sessionProjections: 'packages/session/session-projection/lib/index.js', }).map(([key, path]) => [key, artifactUrl(path)])) const script = ` import { createServer } from 'node:http' @@ -53,6 +55,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const connectionHost = await import(urls.connectionHost) const { default: TypertRemoteService } = await import(urls.apiGatewayHost) const { default: GoalService } = await import(urls.goal) + const { default: SessionProjectionRegistry } = await import(urls.sessionProjections) const { TYPERT } = await import(urls.goalTypert) const { default: TypertRegistry } = await import(urls.registryHost) const { Session, SessionId } = await import(urls.session) @@ -71,6 +74,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { await host.plugin(TypertRegistry) await host.plugin(AgentRegistry) await host.plugin(TypertRemoteService) + await host.plugin(SessionProjectionRegistry) await host.plugin(GoalService) host.typert.register(TYPERT) diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 9c9416905f..0bc28652bc 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index 2cf07d3f70..80983c0ec7 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -23,6 +23,7 @@ import type { TokenUsage, } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent' import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner' @@ -72,6 +73,9 @@ class RoutedContextAdapter extends LlmAdapter { function createContext(contextWindow = 1_000): Context { const ctx = new Context() void new LlmRuntime(ctx) + // The registry is a required injection of TokenMeter (its three + // projection units register in the constructor); mount it synchronously. + new SessionProjectionRegistry(ctx) void new TokenMeter(ctx) ctx.llm.registerAdapter([MODEL, 'actual', 'unlisted-provider'], new ContextAdapter(contextWindow)) return ctx @@ -518,6 +522,7 @@ describe('pressure measurement and retention', () => { it('re-resolves capacity after a same-model-id provider switch in one session', async () => { const ctx = new Context() void new LlmRuntime(ctx) + new SessionProjectionRegistry(ctx) void new TokenMeter(ctx) ctx.llm.registerAdapter(['large', 'small'], new RoutedContextAdapter({ large: 10_000, @@ -545,6 +550,7 @@ describe('pressure measurement and retention', () => { it('requires capacity only for proactive pressure, not provider-confirmed overflow', async () => { const ctx = new Context() void new LlmRuntime(ctx) + new SessionProjectionRegistry(ctx) void new TokenMeter(ctx) ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000)) vi.spyOn(ctx.llm, 'resolveModelInfo').mockImplementation((provider, model) => Promise.resolve({ @@ -1162,6 +1168,7 @@ async function summarizerHarness( ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactionEngine }> { const ctx = new Context() await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionProjectionRegistry) void new TokenMeter(ctx) const adapter = new ScriptedAdapter(blocks, finish) ctx.llm.registerAdapter([model], adapter) @@ -1338,6 +1345,7 @@ describe('default one-shot summarizer', () => { it('fails clearly when no complete summarization target can be resolved', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionProjectionRegistry) void new TokenMeter(ctx) const compact = new ExposedCompactionEngine(ctx, { auto: false }) await expect(compact.runSummarize(promptInput('history'), agent(Session.create(SessionId('model-less'))))) @@ -1852,6 +1860,7 @@ describe('automatic listener and loader composition', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const meterFiber = await ctx.plugin(TokenMeter) const compactFiber = await ctx.plugin(BasicCompactionEngine, { auto: false }) @@ -1865,6 +1874,7 @@ describe('automatic listener and loader composition', () => { it('removes its automatic listener with the plugin fiber', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TokenMeter) const fiber = await ctx.plugin(TestCompactionEngine, { thresholdRatio: 0.5, diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 867d11c2a6..846b7f8576 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -13,6 +13,7 @@ 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 { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import * as LlmRetry from '@deepseek-ai/dsh-llm-retry' import { Session, SessionId, type SessionEvent, type SurfaceEvent } from '@deepseek-ai/dsh-session' @@ -150,6 +151,9 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) + // AgentLoop and TokenMeter both declare the registry as a required + // injection; mount it before either activates. + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeter) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) @@ -312,6 +316,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', const adapter = new OverflowRecoveryAdapter(delivery) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeter) ctx.llm.registerAdapter(['mock'], adapter) @@ -390,6 +395,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', const adapter = new OverflowRecoveryAdapter('thrown', true) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(LlmRetry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeter) diff --git a/packages/compaction/compaction-basic/tests/loader-composition.spec.ts b/packages/compaction/compaction-basic/tests/loader-composition.spec.ts index c4efe48f97..1fc9d86bf9 100644 --- a/packages/compaction/compaction-basic/tests/loader-composition.spec.ts +++ b/packages/compaction/compaction-basic/tests/loader-composition.spec.ts @@ -8,6 +8,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import BasicCompactionEngine from '@deepseek-ai/dsh-compaction-basic' import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner' @@ -34,6 +35,7 @@ async function loadYaml(lines: readonly string[]): Promise { const modules = new Map([ ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-token-meter', TokenMeter], ['@deepseek-ai/dsh-compaction-tool-result-pruner', ToolResultPruner], ['@deepseek-ai/dsh-compaction-basic', BasicCompactionEngine], @@ -58,6 +60,7 @@ describe('real Loader composition', () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-token-meter'", "- name: '@deepseek-ai/dsh-compaction-tool-result-pruner'", ' config:', @@ -86,6 +89,7 @@ describe('real Loader composition', () => { it('rejects stale token-meter config after Schemastery normalization', async () => { context = new Context() + await context.plugin(SessionProjectionRegistry) await expect(context.plugin(TokenMeter, { contextWindow: 4096, } as never)).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/) @@ -95,6 +99,7 @@ describe('real Loader composition', () => { context = new Context() await context.plugin(LlmRuntime) await context.plugin(SessionStore) + await context.plugin(SessionProjectionRegistry) await context.plugin(TokenMeter) await expect(context.plugin(BasicCompactionEngine, { models: { legacy: { thresholdRatio: 0.5 } }, @@ -105,6 +110,7 @@ describe('real Loader composition', () => { context = new Context() await context.plugin(LlmRuntime) await context.plugin(SessionStore) + await context.plugin(SessionProjectionRegistry) await context.plugin(TokenMeter) await expect(context.plugin(BasicCompactionEngine, { retainRatio: 0.2, @@ -120,6 +126,7 @@ describe('real Loader composition', () => { context = new Context() await context.plugin(LlmRuntime) await context.plugin(SessionStore) + await context.plugin(SessionProjectionRegistry) await context.plugin(TokenMeter) await expect(context.plugin(BasicCompactionEngine, { summarizationProvider: 'default-provider', diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index 9e400f7741..a0497f8041 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -25,6 +25,7 @@ import type { TokenUsage, } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LlmRuntime from '@deepseek-ai/dsh-llm' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -105,6 +106,7 @@ async function loopHarness(): Promise { await ctx.plugin(AgentLoopInvariant) await ctx.plugin(CompactionInvariant) await ctx.plugin(CompactionBasicInvariant) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeter) const adapter = new TextAdapter() @@ -221,6 +223,7 @@ function detachedService(): { ctx: Context; compact: GatedCompactionEngine; flus const ctx = new Context() void new LlmRuntime(ctx) void new SessionStore(ctx) + new SessionProjectionRegistry(ctx) void new TokenMeter(ctx) ctx.llm.registerAdapter([MODEL], new TextAdapter()) let flushes = 0 diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 8a3d5bea87..1b2621a39d 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -50,6 +50,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/compaction/compaction-tool-result-pruner/tests/loader-composition.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/loader-composition.spec.ts index 7162524078..d70283b98a 100644 --- a/packages/compaction/compaction-tool-result-pruner/tests/loader-composition.spec.ts +++ b/packages/compaction/compaction-tool-result-pruner/tests/loader-composition.spec.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner' @@ -24,6 +25,7 @@ describe('compaction-tool-result-pruner real Loader composition', () => { root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-token-meter'", "- name: '@deepseek-ai/dsh-compaction-tool-result-pruner'", ' config:', @@ -40,6 +42,7 @@ describe('compaction-tool-result-pruner real Loader composition', () => { context.loader.internal = { version: 'v2', async import(specifier: string) { + if (specifier === '@deepseek-ai/dsh-session-projection') return SessionProjectionRegistry if (specifier === '@deepseek-ai/dsh-token-meter') return TokenMeter if (specifier === '@deepseek-ai/dsh-compaction-tool-result-pruner') return ToolResultPruner throw new Error(`unexpected Loader import: ${specifier}`) @@ -61,8 +64,9 @@ describe('compaction-tool-result-pruner real Loader composition', () => { it('rejects stale config after plugin schema normalization', async () => { context = new Context() - // Satisfy the declared injection first: config normalization runs in the + // Satisfy the declared injections first: config normalization runs in the // service constructor, which a pending fiber never reaches. + await context.plugin(SessionProjectionRegistry) await context.plugin(TokenMeter) await expect(context.plugin(ToolResultPruner, { maxChars: 100, diff --git a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts index 3216a6e774..730b36a7fb 100644 --- a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts +++ b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts @@ -9,6 +9,7 @@ import SessionStore, { import type { SurfaceEvent } from '@deepseek-ai/dsh-session' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import ToolResultPruner, { codePointLength, @@ -29,12 +30,15 @@ function service(config: ToolResultPruneConfig = SMALL): ToolResultPruner { const ctx = new Context() // Service constructors self-register, so `ctx.tokenMeter` resolves for the // shadow-price pricing without a full plugin boot. + new SessionProjectionRegistry(ctx) void new TokenMeter(ctx) return new ToolResultPruner(ctx, config) } /** Pricing oracle mirroring the service's estimator for expectations. */ -const METER = new TokenMeter(new Context()) +const METER_CTX = new Context() +new SessionProjectionRegistry(METER_CTX) +const METER = new TokenMeter(METER_CTX) function appendToolStep( session: Session, @@ -258,6 +262,7 @@ describe('ToolResultPruner 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(SessionProjectionRegistry) await ctx.plugin(InvariantRegistry) await ctx.plugin(SessionInvariant) await ctx.plugin(TokenMeter) diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index d7b3cdf81d..bf35d69e10 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -39,10 +39,12 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -58,6 +60,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index 1b00960adb..f4bd2bed31 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -15,11 +15,13 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-projection' import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts' import { findProjectRoot, loadBaselineInstructionSet } from './files.ts' import { applyInstructionVersionUpdates, baselineInstructionState, + createWorkspaceInstructionsProjection, name, reconcileInstructionContext, workspaceContextMessage, @@ -77,7 +79,12 @@ function filePathFromExecution(exec: ToolExecution): string | undefined { return filePath.length > 0 ? filePath : undefined } +/** Required services (the projection registry drives the instruction fold). */ +export const inject = ['sessionProjections'] + export function apply(ctx: Context, config: Config): void { + ctx.sessionProjections.register(createWorkspaceInstructionsProjection()) + const resolved: ResolvedConfig = resolveConfig(config) const instructionVersions: InstructionVersionCache = new WeakMap() const baselinePreparations = new WeakMap + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Latest workspace-instruction change by scope. */ + workspaceInstructions: WorkspaceInstructionsState + } +} + +/** + * Create the workspace-instruction projection. + * @returns Workspace-instruction projection definition. + */ +export function createWorkspaceInstructionsProjection(): ProjectionDefinition<'workspaceInstructions', WorkspaceInstructionsState> { + return { + key: 'workspaceInstructions', + stateSchema: workspaceInstructionsStateSchema, + init: () => ({}), + apply: (state, event) => { + if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) return state + const changes = workspaceInstructionChanges(event.data.source) + if (changes.length === 0) return state + let next = state + for (const change of changes) { + next = { ...next, [change.scope]: { change, seq: event.seq } } + } + return next + }, + stateVersion: 1, + } +} + function visibleInstructionChanges( agent: Agent, authorityMessages: readonly UserMessage[], ): Map { const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() - for (const [seq, event] of agent.session.events.entries()) { - if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue - const changes = workspaceInstructionChanges(event.data.source) - for (const change of changes) { - if (visibleSeqs.has(seq)) visible.set(change.scope, change) - } + const folded = agent.ctx.get('sessionProjections')?.stateOf(agent.session, 'workspaceInstructions') + if (folded === undefined) throw new Error('workspaceInstructions projection is not registered') + for (const [scope, record] of Object.entries(folded)) { + if (visibleSeqs.has(record.seq)) visible.set(scope, record.change) } for (const message of authorityMessages) { if (!isWorkspaceContextSource(message.source)) continue diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index 392936b4c2..7f468bb6c8 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -11,6 +11,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as WorkspaceContext from '@deepseek-ai/dsh-agent-instructions' import { candidateScopeKey } from '../src/render.ts' @@ -39,6 +40,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> { ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: 'Answer the user exactly and concisely.' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index fe37fef48a..d9caebea84 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -7,6 +7,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions' import LlmRuntime, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' @@ -36,6 +37,7 @@ import { import { applyInstructionVersionUpdates, baselineInstructionState, + createWorkspaceInstructionsProjection, reconcileInstructionContext, type InstructionVersionCache, } from '../src/state.ts' @@ -167,9 +169,16 @@ class BlockingReadFileSystem extends RecordingFileSystem { } } +async function pluginWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { + if (ctx.get('sessionProjections') === undefined) { + await ctx.plugin(SessionProjectionRegistry) + } + return ctx.plugin(workspaceContext, config) +} + async function mountWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) - return ctx.plugin(workspaceContext, config) + return pluginWorkspaceContext(ctx, config) } async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { @@ -177,14 +186,17 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace await ctx.plugin(ToolRuntime) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - return ctx.plugin(workspaceContext, config) + return pluginWorkspaceContext(ctx, config) } function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { const id = SessionId('s1') const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) + const ctx = new Context() + new SessionProjectionRegistry(ctx) + ctx.sessionProjections.register(createWorkspaceInstructionsProjection()) return { - ctx: new Context(), + ctx, id: SessionId('a1'), options: {}, session, @@ -193,7 +205,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { send: () => {}, followup: () => {}, steer: () => {}, - inject: () => { throw new Error('agent-instructions must append directly to the open step') }, + inject: () => { throw new Error('workspace-context must append directly to the open step') }, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), @@ -223,7 +235,7 @@ async function workspaceContextOf(agent: Agent): Promise { message.source.kind === 'agent-instructions') expect(context).toBeDefined() return context! - }) + }, { timeout: 10_000 }) } async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise { @@ -622,10 +634,10 @@ describe('workspace context instruction discovery', () => { it('labels the default DSH home as ~/.dsh when HOME points at the configured default', async () => { const root = await tempRepo() const home = await tempRepo() + vi.stubEnv('DSH_HOME', '') try { await write(join(home, '.dsh/AGENTS.md'), 'global default rule') - // A set DSH_HOME would override the homedir default and relabel the home. vi.stubEnv('DSH_HOME', '') vi.resetModules() vi.doMock('node:os', () => ({ homedir: () => home })) @@ -985,26 +997,27 @@ describe('workspace context request injection', () => { it('requires an explicit maxBytes configuration', async () => { const ctx = new Context() - await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) + await expect(pluginWorkspaceContext(ctx, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) }) it('mounts without requiring a filesystem provider', async () => { const ctx = new Context() try { - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) } finally { await ctx.fiber.dispose() } }) it('does not declare fs as a static inject dependency', () => { - expect('inject' in workspaceContext).toBe(false) + expect(workspaceContext.inject).toEqual(['sessionProjections']) + expect(workspaceContext.inject).not.toContain('fs') }) it('does not inject baseline context when no filesystem provider is present', async () => { const ctx = new Context() try { - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) const agent = stubAgent('/virtual/repo') await composeBaselinePrefix(ctx, agent) @@ -1111,7 +1124,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const original = stubAgent(root) await composeBaselinePrefix(ctx, original) @@ -1355,7 +1368,7 @@ describe('workspace context request injection', () => { expect(inserted?.source).toMatchObject({ kind: 'agent-instructions', baseline: true }) await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) @@ -1401,7 +1414,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) @@ -1454,7 +1467,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) - await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes }) + await pluginWorkspaceContext(resumedCtx, { dshHome: home, maxBytes }) const resumed = stubAgent(root, [...original.session.events]) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) @@ -1564,7 +1577,7 @@ describe('workspace context request injection', () => { const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', - { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, + { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(10_000) }, () => Promise.resolve(downstream), ) @@ -1649,7 +1662,7 @@ describe('workspace context request injection', () => { // Hot remount over the live session: the durable baseline remains // visible, so the fresh mount does not append a duplicate. await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(1) @@ -1679,7 +1692,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + const fiber = await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) const baseline = baselineEvents(agent)[0] @@ -1694,7 +1707,7 @@ describe('workspace context request injection', () => { }) await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(2) @@ -1981,7 +1994,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2004,7 +2017,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2046,7 +2059,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'far too large' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2071,7 +2084,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(instructionPath, { type: 'file', content: 'far too large' }) fs.omitSizes.add(instructionPath) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2094,7 +2107,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as BlockingReadFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'blocked' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel prefix') const pending = agentEvents(ctx, stubAgent(root)).waterfall( @@ -2128,7 +2141,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' }) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2154,7 +2167,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2177,7 +2190,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2200,7 +2213,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2222,7 +2235,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'claude sibling rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2247,7 +2260,7 @@ describe('workspace context request injection', () => { const fs = ctx.fs as RecordingFileSystem fs.throwOnStat.add(join(root, '.git')) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2297,7 +2310,7 @@ describe('workspace context request injection', () => { await write(join(cwd, 'AGENTS.md'), 'child schema default rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) const agent = stubAgent(cwd) await composeBaselinePrefix(ctx, agent) @@ -2318,7 +2331,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.local.md'), 'local rule') const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2514,7 +2527,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) @@ -2590,7 +2603,7 @@ describe('dynamic nested workspace context injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel dynamic reconciliation') controller.abort(reason) @@ -2853,7 +2866,7 @@ describe('dynamic nested workspace context injection', () => { fs.omitSizes.add(instructionPath) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -2890,7 +2903,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-1') }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await ctx.tools.execute({ @@ -2935,7 +2948,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'shared path, separate sessions' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const firstAgent = stubAgent(root) const secondAgent = stubAgent(root) @@ -3471,7 +3484,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'provider package rule' }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -3850,7 +3863,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' }) fs.throwOnRead.add(nested) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const result = await ctx.tools.execute({ @@ -3991,7 +4004,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer policy block' }] } : downstream }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4060,7 +4073,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer composite block' }] } : downstream }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4084,7 +4097,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' }) @@ -4159,7 +4172,7 @@ describe('dynamic nested workspace context injection', () => { agent.session.append('step/start', { turn: 1, step: 1 }) agent.session.append('step/end', { turn: 1, step: 1 }) agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4182,7 +4195,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const agent = stubAgent('/') const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null } @@ -4231,7 +4244,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const root = resolve('/') const agent = stubAgent(root) @@ -4297,7 +4310,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(instructionPath, { type: 'file', content: 'x'.repeat(1000) }) fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 20 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -4419,7 +4432,7 @@ describe('workspace context inbox synchronization', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'tiny-budget rule' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 1 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 1 }) const agent = stubAgent(root) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4495,7 +4508,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'restored A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'restored B' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, @@ -4535,7 +4548,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'scope A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'scope B' }) - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 5fc478314f..7ac1a759a1 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -52,6 +52,8 @@ "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 75619868ad..e33c8398f4 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -4,7 +4,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction' import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' +import { titleProjectionDefinition } from '@deepseek-ai/dsh-session-title' import SessionReferenceResolver, { decodeSessionReferenceUri, encodeSessionReferenceUri, @@ -35,6 +37,8 @@ class TestSessionQueryEngine extends SessionQueryEngine { async function harness(config: Config = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(titleProjectionDefinition) await ctx.plugin(TestSessionQueryEngine) await ctx.plugin(SessionReferenceResolver, config) return ctx diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index bc944e6cfb..c9631ee73a 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -32,13 +32,15 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -50,6 +52,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index a317544a3c..d41f9c27bb 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -7,9 +7,11 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { z as zod } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-session-projection' import { deriveBrowserTimeZoneContext, renderBrowserTimeZoneContext, @@ -20,8 +22,29 @@ import { createTimestampFormatter, formatTimestamp } from './timestamp.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'time-context' +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Latest time-context readings. */ + timeContext: TimeContextProjection + } +} + +const timeContextStateSchema = zod.object({ + /** Time of the latest model-visible event (user/assistant message, tool result), or null. */ + lastMessageTime: zod.number().nullable(), + /** Time of this plugin's latest durable injection, or null. */ + lastInjectionTime: zod.number().nullable(), + /** Turn of the latest injection, or null (a same-turn injection answers precedingStepContextTime). */ + lastInjectionTurn: zod.number().nullable(), + /** The open turn at the latest fold (null between turns); the injection's turn is captured at append. */ + currentTurn: zod.number().nullable(), +}) + +/** Folded time-context readings. */ +export type TimeContextProjection = zod.infer + /** The agent registry that owns pre-step processing. */ -export const inject = ['agents'] +export const inject = ['agents', 'sessionProjections'] /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ export interface Config { @@ -54,47 +77,6 @@ function formatDuration(elapsedMs: number): string { return parts.join(' ') } -/** Find the latest model-visible event, excluding this plugin's pending append. */ -function precedingMessageTime(agent: Agent): number | undefined { - for (const event of [...agent.session.events].reverse()) { - switch (event.type) { - case 'user/message': - case 'assistant/message': - case 'tool/result': - return event.time - default: - // Merge-extensible session events: non-surface records are not messages. - break - } - } - return undefined -} - -/** Find the preceding time-context event within the open turn. */ -function precedingStepContextTime(agent: Agent, turn: number): number | undefined { - for (const event of [...agent.session.events].reverse()) { - if (event.type === 'turn/start' && event.data.turn === turn) return undefined - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === name) { - return event.time - } - } - return undefined -} - -/** Find this plugin's latest durable injection, including a shadowed surface event. */ -function latestInjectionTime(agent: Agent): number | undefined { - for (const event of [...agent.session.events].reverse()) { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === name) { - return event.time - } - } - return undefined -} - /** Collect already-entered and proposed user messages belonging to one open turn. */ function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { const start = agent.session.events.findLastIndex( @@ -167,6 +149,37 @@ export function apply(ctx: Context, config: Config): void { return created } + ctx.sessionProjections.register({ + key: 'timeContext', + stateVersion: 1, + stateSchema: timeContextStateSchema, + init: () => ({ lastMessageTime: null, lastInjectionTime: null, lastInjectionTurn: null, currentTurn: null }), + apply: (state, event) => { + if (event.type === 'turn/start') { + return state.currentTurn === event.data.turn ? state : { ...state, currentTurn: event.data.turn } + } + if (event.type === 'turn/end') { + return state.currentTurn === null ? state : { ...state, currentTurn: null } + } + if (event.type === 'user/message') { + const injected = event.data.source.kind === 'plugin' && event.data.source.plugin === name + const withMessage = state.lastMessageTime === event.time + ? state + : { ...state, lastMessageTime: event.time } + if (!injected) return withMessage + return { + ...withMessage, + lastInjectionTime: event.time, + lastInjectionTurn: state.currentTurn, + } + } + if (event.type === 'assistant/message' || event.type === 'tool/result') { + return state.lastMessageTime === event.time ? state : { ...state, lastMessageTime: event.time } + } + return state + }, + }) + ctx.on('agent/pre-step', async ( { agent, turn, step, signal }, next, @@ -174,15 +187,21 @@ export function apply(ctx: Context, config: Config): void { const decision = await next() if (decision.kind === 'reject' || signal.aborted) return decision const now = Date.now() - if (refreshIntervalMs !== undefined && refreshIntervalMs > 0) { - const lastInjection = latestInjectionTime(agent) - if (lastInjection !== undefined + const state = ctx.sessionProjections.stateOf(agent.session, 'timeContext') + if (state !== undefined && refreshIntervalMs !== undefined && refreshIntervalMs > 0) { + const lastInjection = state.lastInjectionTime + if (lastInjection != null && now >= lastInjection && now - lastInjection < refreshIntervalMs) return decision } + /* v8 ignore next 2 -- time-context registers its own unit in apply, so the key is always present */ + if (state === undefined) return decision + /* v8 ignore next 6 -- an injection always records a time, so the lastInjectionTime nullish fallback is unreachable */ const previous = step === 1 - ? precedingMessageTime(agent) - : precedingStepContextTime(agent, turn) + ? state.lastMessageTime ?? undefined + : state.lastInjectionTurn === turn + ? state.lastInjectionTime ?? undefined + : undefined const messages = requestMessages(agent, turn, decision.messages) const browser = deriveBrowserTimeZoneContext(messages) const selectedTimeZone = browser.kind === 'resolved' ? browser.timeZone : fallbackTimeZone diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 833f7e4b9c..49d4c94335 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -7,6 +7,7 @@ import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as timeContext from '@deepseek-ai/dsh-time-context' import type { Config } from '@deepseek-ai/dsh-time-context' @@ -30,6 +31,7 @@ afterEach(() => { async function mount(config: Config = {}) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(timeContext, config) return { ctx, fiber } @@ -137,6 +139,7 @@ class ScriptedAdapter extends LlmAdapter { async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(timeContext, config) ctx.llm.registerAdapter(['mock'], adapter) @@ -373,6 +376,7 @@ describe('configuration and lifecycle', () => { it('fails loud for an invalid explicit zone or an unavailable process zone', async () => { const invalid = new Context() + await invalid.plugin(SessionProjectionRegistry) await invalid.plugin(AgentRegistry) await expect(invalid.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow( /invalid IANA timeZone/, @@ -382,6 +386,7 @@ describe('configuration and lifecycle', () => { throw new RangeError('system zone unavailable') }) const unresolved = new Context() + await unresolved.plugin(SessionProjectionRegistry) await unresolved.plugin(AgentRegistry) await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/) }) @@ -409,6 +414,27 @@ describe('configuration and lifecycle', () => { }) }) +describe('time-context projection fold edges', () => { + it('keeps the same turn when a repeated turn/start lands', async () => { + const { ctx } = await mount() + const session = Session.create(SessionId('same-turn')) + session.append('turn/start', { turn: 1 }) + session.append('turn/start', { turn: 1 }) + expect(ctx.sessionProjections.snapshot(session).values.timeContext).toMatchObject({ + currentTurn: 1, + }) + }) + + it('stays null when turn/end arrives without a turn/start', async () => { + const { ctx } = await mount() + const session = Session.create(SessionId('end-without-start')) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(ctx.sessionProjections.snapshot(session).values.timeContext).toMatchObject({ + currentTurn: null, + }) + }) +}) + describe('real agent-loop request history', () => { it.each([ ['throws'], @@ -485,11 +511,12 @@ describe('real Loader export path', () => { const unwrapped = loader.unwrapExports(timeContext) as Record expect(unwrapped).toBe(timeContext) expect(unwrapped.name).toBe('time-context') - expect(unwrapped.inject).toEqual(['agents']) + expect(unwrapped.inject).toEqual(['agents', 'sessionProjections']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) const plugin = loader.unwrapExports(timeContext) as Parameters[0] await ctx.plugin(plugin) diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index ad66ca2a15..bb6a7d6927 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -4,7 +4,9 @@ "rootDir": "src", "outDir": "lib/types" }, - "include": ["src"], + "include": [ + "src" + ], "references": [ { "path": "../../../vendor/cosmokit" @@ -35,6 +37,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 2209d53122..11cd3c17b0 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -32,14 +32,16 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -48,6 +50,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index 10ac6a6ab6..155a55ac9f 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -20,7 +20,9 @@ import type { Context, LoggerService } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' +import { z as zod } from 'zod' +import type { PreStepDecision } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-projection' import type { ShellExecutor, ShellRunResult } from '@deepseek-ai/dsh-shell' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -28,7 +30,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' export const name = 'tmux-context' /** The agent registry that owns pre-step processing. */ -export const inject = ['agents'] +export const inject = ['agents', 'sessionProjections'] /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ export interface Config { @@ -178,21 +180,6 @@ function renderReading(location: TmuxLocation, turn: number): string { * schedule survives compaction and resumed processes without process-local * cache state. */ -function latestInjectedState(agent: Agent): { state: string; time: number } | undefined { - for (const event of [...agent.session.events].reverse()) { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === name) { - const [block] = event.data.content - if (block?.type !== 'text') return undefined - const newline = block.text.indexOf('\n') - const state = newline === -1 ? '' : block.text.slice(newline + 1) - return { state, time: event.time } - } - } - return undefined -} - /** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */ function validateRefreshInterval(refreshIntervalMs: number | undefined): void { if (refreshIntervalMs !== undefined && ( @@ -205,16 +192,47 @@ function validateRefreshInterval(refreshIntervalMs: number | undefined): void { } } +const tmuxContextStateSchema = zod.object({ + state: zod.string(), + time: zod.number(), +}).nullable() + +type TmuxContextState = zod.infer + /** * Register a prepended pre-step listener for the lifetime of `ctx`. * @param ctx - plugin context; the listener is disposed with it. * @param config - durable refresh scheduling configuration. * @throws when the refresh interval is invalid. */ +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** The stable state block of this plugin's latest durable injection, or null. */ + tmuxContext: TmuxContextState + } +} + export function apply(ctx: Context, config: Config): void { const refreshIntervalMs = config.refreshIntervalMs validateRefreshInterval(refreshIntervalMs) + ctx.sessionProjections.register({ + key: 'tmuxContext', + stateVersion: 1, + stateSchema: tmuxContextStateSchema, + init: () => null, + apply: (state, event) => { + if (event.type !== 'user/message' + || event.data.source.kind !== 'plugin' + || event.data.source.plugin !== name) return state + const [block] = event.data.content + if (block?.type !== 'text') return state + const newline = block.text.indexOf('\n') + const stableState = newline === -1 ? '' : block.text.slice(newline + 1) + return { state: stableState, time: event.time } + }, + }) + ctx.on('agent/pre-step', async ( { agent, turn, step, signal }, next, @@ -223,15 +241,15 @@ export function apply(ctx: Context, config: Config): void { if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision const bash = ctx.get('shell') if (bash === undefined) return decision - const previous = latestInjectedState(agent) - if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) { + const previous = ctx.sessionProjections.stateOf(agent.session, 'tmuxContext') ?? null + if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== null) { const now = Date.now() if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision } const location = await queryTmuxLocation(bash, ctx.logger, process.pid, signal) if (location === undefined) return decision const state = renderState(location) - if (previous !== undefined && previous.state === state) return decision + if (previous !== null && previous.state === state) return decision const text = renderReading(location, turn) return { kind: 'enter', diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 94b058b2d1..af6de0f6b6 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' @@ -82,6 +83,7 @@ async function mount( ): Promise<{ ctx: Context; bash: FakeBash | undefined }> { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) let bash: FakeBash | undefined if (withBash) { await ctx.plugin(FakeBash) diff --git a/packages/context/tmux-context/tsconfig.json b/packages/context/tmux-context/tsconfig.json index 2dda9e0f8a..42fa931fa2 100644 --- a/packages/context/tmux-context/tsconfig.json +++ b/packages/context/tmux-context/tsconfig.json @@ -4,7 +4,9 @@ "rootDir": "src", "outDir": "lib/types" }, - "include": ["src"], + "include": [ + "src" + ], "references": [ { "path": "../../../vendor/cosmokit" @@ -32,6 +34,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index b922d98f59..2978827f20 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -37,13 +37,15 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -53,9 +55,10 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 668ef65826..fb9f764183 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -31,6 +31,7 @@ import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, Us import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' import { RuntimeContextProjection } from './runtime-context.ts' import { executeToolCalls } from './tool-calls.ts' @@ -89,7 +90,8 @@ export class ReactLoopAgent implements Agent { discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) }, claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) }, }) - const lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 + /* v8 ignore next -- the loop registers its own turnBoundary unit, so the key is always present */ + const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 0 this.phase = { kind: 'idle', lastTurn } this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx.extend({ agent: this }) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 371154a7c9..d948b0cb24 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -8,6 +8,7 @@ import { Context, FiberState, Service } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' import z from '@deepseek-ai/schemastery' +import { z as zod } from 'zod' import { emitAgentEvent } from '@deepseek-ai/dsh-agent' import type { Agent, @@ -18,6 +19,7 @@ import type { CreateAgentOptions, ResumeAgentOptions, SessionStartSource, + TurnBoundaryProjection, } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -25,6 +27,8 @@ import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { ReactLoopAgent } from './agent.ts' import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' @@ -36,6 +40,61 @@ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.FAILED, ]) +const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ + openTurn: zod.number().int().nonnegative().nullable(), + openTurnStartSeq: zod.number().int().nonnegative().nullable(), + lastStepStartSeq: zod.number().int().nonnegative().nullable(), + lastStepBoundary: zod.object({ + kind: zod.union([zod.literal('start'), zod.literal('end')]), + seq: zod.number().int().nonnegative(), + }).nullable(), + lastTurn: zod.number().int().nonnegative(), +}) + +/** Host projection of agent turn and step boundaries. */ +export const turnBoundaryProjectionDefinition = { + key: 'turnBoundary', + stateVersion: 1, + stateSchema: turnBoundaryProjectionSchema, + init: () => ({ + openTurn: null, + openTurnStartSeq: null, + lastStepStartSeq: null, + lastStepBoundary: null, + lastTurn: 0, + }), + apply: (state, event) => { + switch (event.type) { + case 'turn/start': + return { + ...state, + openTurn: event.data.turn, + openTurnStartSeq: event.seq, + lastTurn: event.data.turn, + } + case 'turn/end': + return { + ...state, + openTurn: null, + openTurnStartSeq: null, + } + case 'step/start': + return { + ...state, + lastStepStartSeq: event.seq, + lastStepBoundary: { kind: 'start', seq: event.seq }, + } + case 'step/end': + return { + ...state, + lastStepBoundary: { kind: 'end', seq: event.seq }, + } + default: + return state + } + }, +} satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection> + /** Factory-level ownership: live agent teardowns plus config startup work. */ class FactoryOwnership { private accepting = true @@ -294,7 +353,7 @@ function validateConfiguredAgents(agents: Config['agents']): void { /** Concrete agent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { - static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] + static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt', 'sessionProjections'] /** Runtime schema for declarative agents. */ static Config = z.object({ @@ -318,6 +377,9 @@ export class AgentLoop extends Service implements AgentFactory { constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') + + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + const entry: AgentLoopSettings = { maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls), } diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 0cd3ac39e4..9444fce0b6 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LlmRuntime, { createUserMessage, CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -21,6 +22,7 @@ async function harness(adapter: LlmAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) const agentsFiber = await ctx.plugin(AgentRegistry) @@ -121,6 +123,7 @@ describe('AgentLoop initiator scope', () => { const adapter = new OverlapAdapter(ctx) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -380,6 +383,7 @@ describe('AgentLoop initiator scope', () => { const adapter = new ReloadAdapter() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 29073353e3..fc39e46a28 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -13,6 +14,7 @@ async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 28423bb430..a7cfcbafd8 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -15,6 +15,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' function driverDone(agent: Agent): Promise { @@ -25,6 +26,7 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -514,6 +516,7 @@ describe('Agent.cancel()', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index b08c076dad..a19b5da3f5 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -12,6 +12,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] @@ -29,6 +30,7 @@ async function makeCoreContext(): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -327,6 +329,7 @@ describe('config-driven session id', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -352,6 +355,7 @@ describe('config-driven session id', () => { const ctx1 = new Context() await ctx1.plugin(LlmRuntime) await ctx1.plugin(SessionStore) + await ctx1.plugin(SessionProjectionRegistry) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) await ctx1.plugin(AgentRegistry) @@ -371,6 +375,7 @@ describe('config-driven session id', () => { const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -395,6 +400,7 @@ describe('config-driven session id', () => { const ctx1 = new Context() await ctx1.plugin(LlmRuntime) await ctx1.plugin(SessionStore) + await ctx1.plugin(SessionProjectionRegistry) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) await ctx1.plugin(AgentRegistry) @@ -411,6 +417,7 @@ describe('config-driven session id', () => { const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -436,6 +443,7 @@ describe('config-driven session id', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index c085dc7de0..a4aa7ae63f 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -6,6 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { ReactLoopAgent } from '../src/agent.ts' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' @@ -30,6 +31,7 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -525,6 +527,7 @@ describe('turn numbering continues across seeded sessions', () => { const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -676,6 +679,7 @@ describe('turn and step boundary recovery', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1108,6 +1112,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1158,6 +1163,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1208,6 +1214,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1254,6 +1261,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1302,6 +1310,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 21506c9a0b..11a12e807f 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -8,6 +8,7 @@ import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' function driverDone(agent: Agent): Promise { @@ -18,6 +19,7 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 72e3165f78..d7926a5037 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -16,6 +16,7 @@ import AgentRegistry, { } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** @@ -31,6 +32,7 @@ async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 2105b86f42..170bf9c70c 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -7,6 +7,7 @@ import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' function driverDone(agent: Agent): Promise { @@ -17,6 +18,7 @@ async function harness(adapter: MockAdapter, persona = '') { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1433,6 +1435,7 @@ describe('agent loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1457,6 +1460,7 @@ describe('agent loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 2757a633be..38d135abb3 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -20,6 +20,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import fc from 'fast-check' /** A never-exhausting adapter: every model call returns the same short reply. */ @@ -39,6 +40,7 @@ async function harness() { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index d47fbccbb1..71633906b3 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -8,6 +8,7 @@ import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' /** @@ -41,6 +42,7 @@ async function loopHarness(): Promise { const created = new Context() await created.plugin(LlmRuntime) await created.plugin(SessionStore) + await created.plugin(SessionProjectionRegistry) await created.plugin(SystemPrompt, { persona: SYSTEM }) await created.plugin(ToolRuntime) await created.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 9d1c2a42c3..924a915160 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LlmRuntime, { createUserMessage, LlmError } from '@deepseek-ai/dsh-llm' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -13,6 +14,7 @@ async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 287e73303c..a354356ea2 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -15,6 +15,7 @@ import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, persona = 'stable base') { @@ -28,6 +29,7 @@ async function harnessRoutes( const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -255,6 +257,7 @@ describe('request stability across the loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: 'stable base' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -373,6 +376,7 @@ describe('request stability across the loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: 'stable base' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 1c140e1b2e..df2849d39d 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] @@ -28,6 +29,7 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter): Promi const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -244,6 +246,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -272,6 +275,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -522,6 +526,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -585,6 +590,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -617,6 +623,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -653,6 +660,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx2 = new Context() await ctx2.plugin(LlmRuntime) await ctx2.plugin(SessionStore) + await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) await ctx2.plugin(AgentRegistry) @@ -686,6 +694,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -875,6 +884,7 @@ describe('configured-start failure edges', () => { const configured = new Context() await configured.plugin(LlmRuntime) await configured.plugin(SessionStore) + await configured.plugin(SessionProjectionRegistry) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) await configured.plugin(AgentRegistry) @@ -920,6 +930,7 @@ describe('configured-start failure edges', () => { const configured = new Context() await configured.plugin(LlmRuntime) await configured.plugin(SessionStore) + await configured.plugin(SessionProjectionRegistry) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) await configured.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index abe3730fd1..6b78213200 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -10,6 +10,7 @@ import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -17,6 +18,7 @@ async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textRespo const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/settings.spec.ts b/packages/core/agent-loop/tests/settings.spec.ts index 4b45baab44..aba9cc47b8 100644 --- a/packages/core/agent-loop/tests/settings.spec.ts +++ b/packages/core/agent-loop/tests/settings.spec.ts @@ -8,6 +8,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SettingsProvider } from '@deepseek-ai/dsh-settings' import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' import AgentLoop, { AGENT_LOOP_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-loop' @@ -34,6 +35,7 @@ async function boot(): Promise<{ ctx: Context; settingsFiber: Fiber; loopFiber: const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 87f69cc7d0..a2fad43978 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -12,6 +12,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import ToolRuntime, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse } from './mock-adapter.ts' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' @@ -20,6 +21,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -267,17 +269,24 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await expect(harness(new MockAdapter([]), 1.5)).rejects.toThrow() }) - it('defensively rejects invalid caps when direct construction bypasses the config schema', () => { - expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 0 })) + it('defensively rejects invalid caps when direct construction bypasses the config schema', async () => { + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + expect(() => new AgentLoop(ctx, { agents: [], maxParallelToolCalls: 0 })) .toThrow('maxParallelToolCalls must be a positive integer') - expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 1.5 })) + await ctx.fiber.dispose() + const other = new Context() + await other.plugin(SessionProjectionRegistry) + expect(() => new AgentLoop(other, { agents: [], maxParallelToolCalls: 1.5 })) .toThrow('maxParallelToolCalls must be a positive integer') + await other.fiber.dispose() }) it('defaults the cap when direct construction bypasses the config schema', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -346,6 +355,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -704,6 +714,7 @@ describe('code-mode native-tool denial through the agent loop', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime, { mode: 'code' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FakeCodeRuntime is an internal test helper with an opaque type shape diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index a9af9f56bb..563e5ba6c3 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -17,12 +17,14 @@ import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json index 1765cb00b3..23dbe218ae 100644 --- a/packages/core/agent-loop/tsconfig.json +++ b/packages/core/agent-loop/tsconfig.json @@ -43,6 +43,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 6144f8679a..cd986a99a4 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -43,7 +43,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -53,6 +54,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 81052096dc..ce42e1f898 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -17,6 +17,7 @@ import type { Agent, AgentOptions } from './runtime-types.ts' export * from './runtime-types.ts' export * from './types.ts' +export type * from './projection.ts' export * from './inbox.ts' export * from './consumed-work.ts' export * from './model-selection.ts' diff --git a/packages/core/agent/src/projection.ts b/packages/core/agent/src/projection.ts new file mode 100644 index 0000000000..89c10ac858 --- /dev/null +++ b/packages/core/agent/src/projection.ts @@ -0,0 +1,11 @@ +import type { TurnBoundaryProjection } from './types.ts' +import type {} from '@deepseek-ai/dsh-session-projection' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** The agent session's open/last turn and step boundary facts (whole value). */ + turnBoundary: TurnBoundaryProjection + } +} + +export {} diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b54e56ea8f..c7c9bfa0f6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -9,6 +9,20 @@ import type { UserMessage } from '@deepseek-ai/dsh-llm/types' /** One of the two ordered pending-message lists owned by an agent. */ export type InboxTarget = 'next-turn' | 'next-step' +/** Turn and step boundaries folded from one agent session log. */ +export interface TurnBoundaryProjection { + /** Open turn number, or null between turns. */ + readonly openTurn: number | null + /** Seq of the open turn's `turn/start`, or null between turns. */ + readonly openTurnStartSeq: number | null + /** Seq of the latest `step/start` event, or null before the first step. */ + readonly lastStepStartSeq: number | null + /** The latest step boundary (`step/start` or `step/end`) and its seq, or null before the first step boundary. */ + readonly lastStepBoundary: { readonly kind: 'start' | 'end'; readonly seq: number } | null + /** Turn number of the latest `turn/start`; 0 before the first turn. */ + readonly lastTurn: number +} + declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index dfa66f16ff..f7aae0283e 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../../session/session-projection" + }, { "path": "../../typert/protocol" } diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index fd27a8822d..23dd010983 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index ec770592a6..ea18cca2ce 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1,8 +1,11 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { createUserMessage, CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import ToolRuntime, { defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, @@ -718,19 +721,21 @@ describe('ToolRuntime', () => { }) describe('ask routing through ctx.approval', () => { - /** - * A minimal Agent stand-in — the approval seam reaches - * `agent.session.append` and folds `.events`; the seeded open turn - * satisfies request()'s enclosure precondition. - */ function fakeAgent(): Agent { - return { - session: { events: [{ type: 'turn/start' }], append: () => ({}) }, - } as unknown as Agent + const session = Session.create(SessionId('approval-fake-agent')) + session.append('turn/start', { turn: 1 }) + return { session } as unknown as Agent } async function approvalSetup() { - const ctx = await setup() + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ApprovalService) ctx.tools.register(echoTool) return ctx diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index de22377405..53dbb684cc 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -71,6 +71,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 24ec62cc02..bc1bc09dd1 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -9,6 +9,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Message } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as acpAgent from '../src/index.ts' /** @@ -32,6 +33,9 @@ async function mount(config: acpAgent.Config, withBash = false): Promise { // `ctx.plugin`, which validates+defaults the config first) with no // persistenceRoot, so the runtime fallback is the one that fires. const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) // No persona: covers the omitted-persona forwarding branch too. await acpAgent.apply(ctx, { provider: 'mock', @@ -154,6 +159,7 @@ describe('dsh-acp-demo composition', () => { it('uses default skill config when apply is called directly without skills', async () => { await withIsolatedSkillHomes(async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await acpAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false }) expect(ctx.skills).toBeDefined() expect(await ctx.skills.list()).toEqual([]) diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 9f872e6dd6..aff408bd27 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -92,7 +92,8 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 87098c38da..f67efff4bb 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -13,6 +13,7 @@ import Timer from '@deepseek-ai/cordis-plugin-timer' import z from '@deepseek-ai/schemastery' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title' import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' @@ -220,6 +221,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(Timer) ctx.plugin(LlmRuntime) ctx.plugin(SessionStore) + ctx.plugin(SessionProjectionRegistry) ctx.plugin(SessionTitleService, config.sessionTitle ?? EXAMPLE_SESSION_TITLE_CONFIG) // Owner schemas resolve defaults; forward toolOrder only when explicitly set. ctx.plugin(SystemPrompt, { 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 56f7903fcb..9da8db372a 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -8,6 +8,7 @@ import { renderPrompt, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' @@ -77,6 +78,9 @@ async function mount(config: agentCore.Config, withBash = false): Promise { // ctx.plugin validates + defaults the bundle config first; a direct apply // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) agentCore.apply(ctx, { workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.get('agentLoop')).toBeDefined() @@ -337,6 +342,7 @@ describe('dsh-agent-spine-demo bundle', () => { it('uses owner defaults for a schema-bypassing empty goal opt-in', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) agentCore.apply(ctx, { workspaceContext: false, agents: [{ id: SessionId('defaulted-goal'), provider: 'mock', model: 'mock' }], @@ -772,6 +778,7 @@ describe('dsh-agent-spine-demo bundle', () => { it('uses the default skill config when apply is called directly without skills', async () => { await withIsolatedSkillHomes(async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) agentCore.apply(ctx, { agents: [], workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) expect(ctx.skills).toBeDefined() @@ -800,6 +807,7 @@ describe('dsh-agent-spine-demo bundle', () => { it('supports direct apply with workspace instructions disabled and no forwarded agents', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) agentCore.apply(ctx, { workspaceContext: false }) await new Promise(resolve => setTimeout(resolve, 50)) diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts index c9fe5fe799..e291309d6c 100644 --- a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -12,6 +12,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SessionId } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import type { ToolResult } from '@deepseek-ai/dsh-tools' @@ -52,6 +53,7 @@ beforeEach(async () => { ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 }) diff --git a/packages/experimental/team/package.json b/packages/experimental/team/package.json index 8a2423093a..f68d1da12f 100644 --- a/packages/experimental/team/package.json +++ b/packages/experimental/team/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", diff --git a/packages/experimental/team/tests/persistence.spec.ts b/packages/experimental/team/tests/persistence.spec.ts index 121a98445c..cc649d0f5b 100644 --- a/packages/experimental/team/tests/persistence.spec.ts +++ b/packages/experimental/team/tests/persistence.spec.ts @@ -9,6 +9,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite' import SubagentService, { seedDescriptorTurn, snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' @@ -93,6 +94,7 @@ async function stack( const ctx = new Context() contexts.add(ctx) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await backend.mount(ctx, root) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) diff --git a/packages/experimental/team/tests/team.spec.ts b/packages/experimental/team/tests/team.spec.ts index dd03cd6438..5c36947a48 100644 --- a/packages/experimental/team/tests/team.spec.ts +++ b/packages/experimental/team/tests/team.spec.ts @@ -8,6 +8,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId, type Session } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' @@ -45,6 +46,7 @@ async function setup( ) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-')) roots.push(storageRoot) await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) @@ -136,6 +138,7 @@ describe('Team identity and provisioning', () => { it('supports direct-constructor defaults and recovers roots that already exist', async () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-direct-')) roots.push(storageRoot) await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) @@ -1246,6 +1249,7 @@ describe('Team mailbox and waiting', () => { it('waits for one change, supports cancellation, times out, and releases waiters on HMR disposal', async () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-wait-')) roots.push(storageRoot) await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) diff --git a/packages/experimental/tool-team/package.json b/packages/experimental/tool-team/package.json index 3aa6c766e2..49f9af30ce 100644 --- a/packages/experimental/tool-team/package.json +++ b/packages/experimental/tool-team/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/experimental/tool-team/tests/tool-team.spec.ts b/packages/experimental/tool-team/tests/tool-team.spec.ts index f950af4aad..1e94b6eeef 100644 --- a/packages/experimental/tool-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-team/tests/tool-team.spec.ts @@ -9,6 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { CallId } from '@deepseek-ai/dsh-llm' import { scopeOf } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' @@ -44,6 +45,7 @@ afterEach(() => { async function setup(script: ConstructorParameters[0], legacyControl = false) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-tool-team-')) roots.push(storageRoot) await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 283dcde600..1a4c6443ee 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -341,7 +341,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'overrideOf(session: Session): ApprovalPolicy | undefined', - description: 'Read the session override without applying the configured default.', + description: 'Read the projected session override without applying the configured default.', parameters: [{ name: 'session', description: 'session whose log supplies the override.' }], returns: 'the last logged policy, or `undefined` without one.', }, @@ -918,9 +918,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Owns the deployment\'s permission presets and their write path. Requires a confining `ctx.shell` executor and `ctx.approval`; unmatched knob values are reported as CUSTOM_PRESET, not an error.', methods: [ { - signature: 'current(events: readonly SessionEvent[]): string', + signature: 'current(session: Session): string', description: 'Resolve the preset matching the effective knob values. A still-matching last selection wins shared-bundle ties; otherwise the first table match wins, or CUSTOM_PRESET when no entry matches.', - parameters: [{ name: 'events', description: 'the session\'s events in log order.' }], + parameters: [{ name: 'session', description: 'the session whose knob state is read.' }], returns: 'the effective preset name, or `custom` when nothing matches.', }, { @@ -953,7 +953,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'planMode', summary: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool.', - description: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. UIs observe committed flips through `session/event`; there is no live mirror.', + description: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool. Client carriers expose the projection\'s cropped `{ active, pending }` view.', methods: [ { signature: 'get(agent: Agent): { active: boolean; pending?: boolean }', @@ -984,8 +984,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'sandboxPolicy', - summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).', - description: 'The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mode, fallback workspace root, and current request-time policy section. Tool layers call resolve for each execution so a session\'s mode log and immutable cwd travel together to every enforcing capability.', + summary: 'The sandbox policy seam: the deployment default mode and workspace-write root, with per-session overrides folded from the log.', + description: 'The sandbox policy seam: the deployment default mode and workspace-write root, with per-session overrides folded from the log.', methods: [ { signature: 'readonly defaultMode: SandboxMode', @@ -1110,7 +1110,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject([\'sessionProjections\'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. Unit contributors and host readers require this service, so incomplete compositions fail during activation. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', methods: [ { signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: ProjectionDefinition & { wire: NonNullable[\'wire\']> }, ): () => void', @@ -1655,10 +1655,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise', - description: 'Enumerate the parent\'s direct session-backed subagents without loading or resuming an Agent and without any query service: the listing merges the live session store with optional session persistence (live-preferred) and serves each child\'s durable mode/label from the registered `subagent` projection unit down a three-rung ladder — the registry\'s watermark snapshot for a live child; for a cold one, a durable projection-cache row when the optional cache serves an own-suffix identity (its `seq` gate proves the value postdates the fork seed, where a child\'s own descriptor is immutable once appended), else one persistence inspection folded through the registry. The projection fold is the single classification authority; per-child diagnostics relay a fold that served no identity or a failed inspection, never a list-time descriptor parse. Absent persistence, enumeration is live-only (a cold child cannot be resumed then either, so its absence is capability absence, not an error). This service consults no Agent registrations, Activations, or providers.\n\nEvery persistence read receives `signal`, and the listing rechecks cancellation around each of those awaits. Read rejections that settle after an abort become a stable `SubagentError` with code `CANCELLED`.', + description: 'Enumerate the parent\'s direct session-backed subagents without loading or resuming an Agent and without any query service: the listing merges the live session store with optional session persistence (live-preferred) and serves each child\'s durable mode/label from the registered `subagent` projection unit down a three-rung ladder — the registry\'s watermark snapshot for a live child; for a cold one, a durable projection-cache row when the optional cache serves an own-suffix identity (its `seq` gate proves the value postdates the fork seed, where a child\'s own descriptor is immutable once appended), else one persistence inspection folded through the same unit. The projection fold is the single classification authority; per-child diagnostics relay a fold that served no identity or a failed inspection, never a list-time descriptor parse. Absent persistence, enumeration is live-only (a cold child cannot be resumed then either, so its absence is capability absence, not an error). This service consults no Agent registrations, Activations, or providers.\n\nEvery persistence read receives `signal`, and the listing rechecks cancellation around each of those awaits. Read rejections that settle after an abort become a stable `SubagentError` with code `CANCELLED`.', parameters: [{ name: 'parentSessionId', description: 'parent session whose direct children are listed.' }, { name: 'signal', description: 'caller-owned cancellation forwarded to persistence reads and observed around every read await.' }], returns: 'children and per-child diagnostics ordered by `createdAt`, then id.', - throws: ['{@link SubagentError} when the projection registry or the session store is not mounted, or the caller cancels the listing.'], + throws: ['{@link SubagentError} when the session store is not mounted, or the caller cancels the listing.'], }, { signature: 'listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise', @@ -2761,7 +2761,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ApprovalService', - declaration: 'export class ApprovalService extends Service {\n static Config: z;\n constructor(ctx: Context, public config: Config);\n setPolicy(agent: Agent, policy: ApprovalPolicy): void;\n async request(req: ApprovalRequest): Promise;\n overrideOf(session: Session): ApprovalPolicy | undefined;\n}', + declaration: 'export class ApprovalService extends Service {\n static Config: z;\n static inject;\n constructor(ctx: Context, public config: Config);\n setPolicy(agent: Agent, policy: ApprovalPolicy): void;\n async request(req: ApprovalRequest): Promise;\n overrideOf(session: Session): ApprovalPolicy | undefined;\n}', }, { name: 'AskUserQuestionAnswer', @@ -4055,10 +4055,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionTitleProvider', declaration: 'export interface SessionTitleProvider {\n readonly id: SessionTitleProviderId;\n readonly automatic: SessionTitleAutomaticMode;\n generate(request: SessionTitleProviderRequest): Promise;\n}', }, - { - name: 'SessionTitleProviderId', - declaration: 'export type SessionTitleProviderId = Branded<\'SessionTitleProviderId\'>;', - }, { name: 'SessionTitleProviderRequest', declaration: 'export interface SessionTitleProviderRequest {\n readonly session: Session;\n readonly messages: readonly SessionTitleUserMessage[];\n readonly route?: SessionTitleModelProvenance;\n readonly signal: AbortSignal;\n}', @@ -4281,7 +4277,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRuntime', - declaration: 'export class SubagentRuntime extends Service {\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise;\n}', + declaration: 'export class SubagentRuntime extends Service {\n static inject;\n constructor(ctx: Context);\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async followup(parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async reportFrom(child: Agent, content: ContentBlock[], options: SubagentReportOptions): Promise;\n registerContinuableSetup(contribution: ContinuableSetupContribution): () => void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 674fc4919f..a752df1844 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index f6aaa0dd81..435086a301 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -17,6 +17,7 @@ import { Context } from '@deepseek-ai/cordis' import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' import type { FsTarget } from '@deepseek-ai/dsh-fs' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox' @@ -29,6 +30,7 @@ let fiber: Awaited> async function boot(mode: SandboxMode): Promise { ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) fiber = await ctx.plugin(SandboxedFileSystem, { cwd: workspace }) fs = ctx.fs as SandboxedFileSystem @@ -171,6 +173,7 @@ describe('workspace-write with the filesystem root as the workspace (a root endi // A degenerate but valid config: the filesystem root containing the target. // It exercises the separator-suffixed-root branch on POSIX and Windows. const rootCtx = new Context() + await rootCtx.plugin(SessionProjectionRegistry) await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: parse(base).root }) const rootFiber = await rootCtx.plugin(SandboxedFileSystem, { cwd: workspace }) const rootFs = rootCtx.fs as SandboxedFileSystem diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 65f326ddcb..b021e6b2f8 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index 2e6dc22cf8..f226e47b71 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' @@ -14,6 +15,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' */ export async function fsHarness(fsCwd: string, persona = ''): Promise { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona } }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index cad6531766..14e92c1eaf 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -9,6 +9,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node: import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type ToolResult } from '@deepseek-ai/dsh-tools' import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' @@ -31,6 +32,7 @@ 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' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' const testToolSignal = new AbortController().signal @@ -792,6 +794,8 @@ describe('sandbox escalation API (write/edit)', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' }) await ctx.plugin(SandboxingFakeFs) await ctx.plugin(FsPolicy) @@ -806,7 +810,7 @@ describe('sandbox escalation API (write/edit)', () => { id: 'agent-fs-esc', session: { header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' }, - events: [{ type: 'turn/start' }, ...events], + events: [{ type: 'turn/start', data: { turn: 1 } }, ...events], append: (type: string, data: Record) => { events.push({ type, data }) }, }, } diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 07af01aa95..5ea86454c8 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index e363666606..34dea8ee7d 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -12,6 +12,7 @@ import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' @@ -76,6 +77,9 @@ async function setup( if (options.sandboxMode === undefined) { await ctx.plugin(LocalFileSystem, { cwd: root }) } else { + // SandboxPolicy declares the registry as a required injection; mount it + // before the policy activates. + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicy, { mode: options.sandboxMode, workspaceRoot: root }) await ctx.plugin(SandboxedFileSystem, { cwd: root }) } diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index f21ecb6333..75904fd71d 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 2127844646..ed126cc02e 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -7,6 +7,7 @@ import CommandRuntime from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as commandGoal from '@deepseek-ai/dsh-command-goal' interface Harness { @@ -46,6 +47,7 @@ async function harness(): Promise { await ctx.plugin(SessionStore) await ctx.plugin(CommandRuntime) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const plugin = await ctx.plugin(commandGoal) const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`) diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index c499037847..fd4fa8bf84 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts index 1bd2032600..3d0bc5aca4 100644 --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts @@ -10,6 +10,7 @@ import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { UserMessage } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as goalSession from '../src/index.ts' type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[]) @@ -89,6 +90,7 @@ async function harness(script: ScriptEntry[]): Promise { const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const driver = await ctx.plugin(goalSession) await ctx.plugin(AgentLoop, { agents: [] }) @@ -213,6 +215,7 @@ describe('same-session goal driving', () => { const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) await ctx.plugin(AgentLoop, { agents: [] }) const adapter = new ScriptedAdapter([textResponse('after resume')]) diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index baf8c921a9..045c773bcd 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -13,7 +13,6 @@ import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' -// Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import { applyGoalEvent, @@ -50,7 +49,7 @@ import type { // The pure payload outlet (./types.ts, ONE home of the `goal` projection-key // declaration) re-exported onto the package root keeps the module edge in // the emitted index.d.ts, so aggregate programs consuming the declarations -// still receive the SessionProjectionMap merge. +// still receive the SessionProjectionStateMap merge. export type * from './types.ts' export type * from './domain.ts' export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' @@ -181,7 +180,7 @@ function resolveBlockReason(reason: unknown): GoalBlockReason { /** Goal service (`ctx.goals`) backed exclusively by the owning session log. */ export class GoalService extends TypertRemoteService { - static inject = ['agents'] + static inject = ['agents', 'sessionProjections'] static Config: z = z.object({ defaultMaxGoalRounds: z.number().default(256), @@ -198,18 +197,13 @@ export class GoalService extends TypertRemoteService { ctx.on('agent/session-start', ({ agent }) => { this.cache(agent.session).activation = 'disarmed' }) - // The `goal` projection unit: last-wins fold of goal/change whole values - // (see applyGoalProjection). The unit child activates only when a - // projection registry is composed (headless assemblies stay unaffected). - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({ - key: 'goal', - stateSchema: goalProjectionSchema, - init: () => null, - apply: applyGoalProjection, - wire: { viewSchema: goalProjectionSchema, view: state => state }, - stateVersion: 4, - }) + ctx.sessionProjections.register<'goal', GoalProjection | null>({ + key: 'goal', + stateVersion: 4, + stateSchema: goalProjectionSchema, + init: () => null, + apply: applyGoalProjection, + wire: { viewSchema: goalProjectionSchema, view: state => state }, }) } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index e94e0754a0..1eb0c90d2e 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -4,6 +4,7 @@ import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import GoalService, { GoalError, GoalId, @@ -60,6 +61,7 @@ function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-sessi async function harness(config: { defaultMaxGoalRounds?: number } = {}) { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService, config) const stub = stubAgent(`goal-test-${Math.random()}`) ctx.agents.register(stub.agent) @@ -131,6 +133,7 @@ describe('GoalService creation and replay', () => { it('also resolves the default when constructed directly without Cordis config normalization', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) const goals = new GoalService(ctx) const stub = stubAgent('goal-direct-construction') ctx.agents.register(stub.agent) @@ -142,6 +145,7 @@ describe('GoalService creation and replay', () => { it('rejects invalid direct configuration', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await expect(ctx.plugin(GoalService, { defaultMaxGoalRounds: -1 })).rejects.toThrow(expect.objectContaining({ code: 'GOAL_INVALID_MAX_ROUNDS', })) @@ -155,6 +159,7 @@ describe('GoalService creation and replay', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const resumed = stubAgent('seeded-goal', first.session.events) ctx.agents.register(resumed.agent) @@ -169,6 +174,7 @@ describe('GoalService creation and replay', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent'))) ctx.agents.register(parent.agent) @@ -215,6 +221,7 @@ describe('GoalService creation and replay', () => { it('removes the service and its session-start listener with the providing fiber', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(GoalService) const first = ctx.goals const stub = stubAgent('goal-hmr') @@ -423,6 +430,7 @@ describe('GoalService mutations', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer'))) ctx.agents.register(stub.agent) @@ -441,6 +449,7 @@ describe('GoalService mutations', () => { it('does not delegate goal persistence to agent injection', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const stub = stubAgent('goal-independent-injection') stub.agent.inject = () => { throw new Error('injection must not be called') } diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 6282a6f739..e5fb642e84 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -1,12 +1,4 @@ -/** - * The `goal` projection unit: mounting GoalService beside the registry - * serves the current whole goal on the history tail page with a consistent - * asOfSeq; before the first create the value is null; a clear tombstone - * returns it to null; a composition without the goal service has no `goal` - * key; unmounting drops it (HMR safety). Malformed goal-shaped events are - * ignored fail-soft (same-reference return) — strict replay validation - * belongs to the write side and foldGoal, never the projection drive. - */ +/** Goal projection behavior. */ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 2b20854351..6a3d20f6b9 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -39,7 +39,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" @@ -47,12 +48,14 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "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:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 1fdfbc740a..31d9256b47 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -6,13 +6,11 @@ import type { GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-projection' -type TurnStartEvent = Extract - -/** Current open turn plus the events accepted after its start boundary. */ +/** The calling agent plus the events accepted after its open turn's start boundary. */ export interface GoalToolExecution { readonly agent: Agent - readonly start: TurnStartEvent readonly events: readonly SessionEvent[] } @@ -26,19 +24,19 @@ function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never { throw new HarnessError(message, code) } -/** Locate the open turn enclosing a model tool call. */ -function openTurn(agent: Agent): { start: TurnStartEvent; events: readonly SessionEvent[] } { +/** + * The event window of the open turn enclosing a model tool call. The + * open-turn boundary comes from the `turnBoundary` projection (one O(1) + * snapshot read); the suffix is the raw event window after the open + * `turn/start` seq. + */ +function openTurnEvents(ctx: Context, agent: Agent): readonly SessionEvent[] { const events = agent.session.events - for (let index = events.length - 1; index >= 0; index -= 1) { - const boundary = events[index] - if (boundary?.type === 'turn/end') { - reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') - } - if (boundary?.type === 'turn/start') { - return { start: boundary, events: events.slice(index + 1) } - } + const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') + if (boundary === undefined || boundary.openTurnStartSeq === null) { + reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') } - return reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') + return events.slice(boundary.openTurnStartSeq + 1) } /** @@ -59,7 +57,7 @@ export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolE 'GOAL_TOOL_DRIVER_REQUIRED', ) } - return { agent, ...openTurn(agent) } + return { agent, events: openTurnEvents(ctx, agent) } } /** diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 903190de4d..b60468e329 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -20,7 +20,7 @@ import { import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' -export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] +export const inject = ['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections'] /** Model policy and hard lower bounds for goal-state updates. */ export interface Config { diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index cb5a2a9874..bced8b3eb8 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' @@ -10,6 +11,7 @@ import type { MessageSource } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' @@ -74,6 +76,8 @@ async function harness(config: toolGoal.Config = {}) { await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(GoalService) const fiber = await ctx.plugin(toolGoal, config) const root = stubAgent(`goal-tool-root-${Math.random()}`) @@ -164,7 +168,7 @@ describe('goal tool registration and presentation', () => { it('has the Loader-safe namespace export shape', () => { expect('default' in toolGoal).toBe(false) expect(toolGoal.name).toBe('tool-goal') - expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt']) + expect(toolGoal.inject).toEqual(['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections']) const loader = Object.create(Loader.prototype) as Loader expect(loader.unwrapExports(toolGoal)).toBe(toolGoal) }) diff --git a/packages/goal/tool-goal/tsconfig.json b/packages/goal/tool-goal/tsconfig.json index 1570cd7d2d..1933a0c8b3 100644 --- a/packages/goal/tool-goal/tsconfig.json +++ b/packages/goal/tool-goal/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 447bc2e08f..133bbc4ffe 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts index c2dda01558..b78b547bd9 100644 --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts @@ -6,6 +6,7 @@ import { defineContentToolFixture } 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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-reminder' import type { Config } from '@deepseek-ai/dsh-repeat-tool-reminder' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -24,6 +25,8 @@ const testToolSignal = new AbortController().signal async function harness(config: Config = {}): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + // AgentLoop declares the registry as a required injection. + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(RepeatToolGuard, config) ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) @@ -370,6 +373,7 @@ describe('config validation fails loud', () => { async function spine(): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) return ctx } diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index 4b393fc8d9..647f99dddf 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -43,7 +43,8 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -60,6 +61,7 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/hooks/hooks-claude-code/src/index.ts b/packages/hooks/hooks-claude-code/src/index.ts index 79c2df194c..9e9bb2b40e 100644 --- a/packages/hooks/hooks-claude-code/src/index.ts +++ b/packages/hooks/hooks-claude-code/src/index.ts @@ -13,6 +13,7 @@ import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -37,9 +38,9 @@ import type { SubagentRunId } from '@deepseek-ai/dsh-subagent' import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts' export const name = 'hooks-claude-code' -// `bash` is required to run hooks; the rest are read opportunistically via -// ctx.get so a deployment can load this bridge without every extension point present. -export const inject = ['shell'] +// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest +// are read opportunistically via ctx.get so a deployment can omit them. +export const inject = ['shell', 'sessionProjections'] /** Plugin config: where the CC hook config lives + substitution roots. */ export interface Config { @@ -236,7 +237,7 @@ export function apply(ctx: Context, config: Config): void { // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. --- ctx.on('tools/pre-execute', async (exec, next): Promise => { - const turn = lastTurn(exec.agent) + const turn = lastTurn(ctx, exec.agent) const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} } @@ -245,7 +246,7 @@ export function apply(ctx: Context, config: Config): void { // --- PostToolUse → PostToolDecision. Matcher subject is the tool name. --- ctx.on('tools/post-execute', async (exec, result, next): Promise => { - const turn = lastTurn(exec.agent) + const turn = lastTurn(ctx, exec.agent) const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { @@ -307,11 +308,9 @@ const SUBAGENT_TYPE = 'general-purpose' // hook input schema; this is the part a bridge owns. --- /** The last open turn number in the agent's log, or 0 without an agent. */ -function lastTurn(agent: Agent | undefined): number { +function lastTurn(ctx: Context, agent: Agent | undefined): number { if (!agent) return 0 - const last = [...agent.session.events].findLast(e => e.type === 'turn/start') - /* v8 ignore next -- agent-present callers are tool/stop extension points inside an open turn. */ - return last?.type === 'turn/start' ? last.data.turn : 0 + return ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')?.lastTurn ?? 0 } /** Flatten content blocks to the text a hook payload carries (the common case). */ diff --git a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts index 670cfd9b04..e58519b577 100644 --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts @@ -15,6 +15,7 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** @@ -57,6 +58,7 @@ async function harnessWithFiber( ): Promise<{ ctx: Context; hooks: Fiber }> { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -355,6 +357,7 @@ describe('hooks-claude-code bridge — load resilience', () => { const adapter = new MockAdapter([textResponse('fine')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -415,6 +418,7 @@ describe('hooks-claude-code bridge — load resilience', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -435,12 +439,12 @@ describe('hooks-claude-code bridge — load resilience', () => { // "cannot get property … without inject". Guard the shape directly. expect('default' in HooksClaude).toBe(false) expect(HooksClaude.name).toBe('hooks-claude-code') - expect(HooksClaude.inject).toEqual(['shell']) + expect(HooksClaude.inject).toEqual(['shell', 'sessionProjections']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(HooksClaude) as Record expect(unwrapped).toBe(HooksClaude) expect(unwrapped.name).toBe('hooks-claude-code') - expect(unwrapped.inject).toEqual(['shell']) + expect(unwrapped.inject).toEqual(['shell', 'sessionProjections']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index 08e20e3e49..71926a0ed1 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -15,6 +15,7 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' const testToolSignal = new AbortController().signal @@ -41,6 +42,7 @@ type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxC async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) @@ -368,6 +370,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -667,6 +670,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the session cwd). await ctx.plugin(LocalSubprocessRuntime) @@ -696,6 +700,7 @@ export function defineCoverageCases(group: CoverageGroup): void { hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' }] }] }) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the child session cwd). await ctx.plugin(LocalSubprocessRuntime) diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 9b40e43fb6..42b69ae8d5 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -42,7 +42,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index a76965c970..3d4d41d8e6 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -16,6 +16,7 @@ import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' @@ -38,7 +39,7 @@ import { parseCodexConfig, type CodexHookConfig } from './config.ts' /* jscpd:ignore-end */ export const name = 'hooks-codex' -export const inject = ['shell'] +export const inject = ['shell', 'sessionProjections'] /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ export interface Config { @@ -223,7 +224,7 @@ export function apply(ctx: Context, config: Config): void { // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). ctx.on('tools/pre-execute', async (exec, next): Promise => { - const turn = lastTurn(exec.agent) + const turn = lastTurn(ctx, exec.agent) const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) /* jscpd:ignore-end */ if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } @@ -232,7 +233,7 @@ export function apply(ctx: Context, config: Config): void { // PostToolUse → PostToolDecision (block with feedback, or attach context). ctx.on('tools/post-execute', async (exec, result, next): Promise => { - const turn = lastTurn(exec.agent) + const turn = lastTurn(ctx, exec.agent) /* jscpd:ignore-start */ const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) @@ -276,11 +277,9 @@ export function apply(ctx: Context, config: Config): void { // These small payload helpers intentionally remain next to the dialect shape; // sharing them would pull bridge-only agent/LLM dependencies into hook-protocol. /* jscpd:ignore-start */ -function lastTurn(agent: Agent | undefined): number { +function lastTurn(ctx: Context, agent: Agent | undefined): number { if (!agent) return 0 - const last = [...agent.session.events].findLast(e => e.type === 'turn/start') - /* v8 ignore next -- agent-present turnBase callers are tool/stop extension points inside an open turn. */ - return last?.type === 'turn/start' ? last.data.turn : 0 + return ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')?.lastTurn ?? 0 } function blocksToText(content: ContentBlock[]): string { @@ -304,7 +303,7 @@ function base(ctx: Context, agent: Agent | undefined, event: string, model: stri /** Base + turn_id, for the turn-scoped events (PreToolUse/PostToolUse/UserPromptSubmit/Stop). */ function turnBase(ctx: Context, agent: Agent | undefined, event: string, model: string): Record { - return { ...base(ctx, agent, event, model), turn_id: String(lastTurn(agent)) } + return { ...base(ctx, agent, event, model), turn_id: String(lastTurn(ctx, agent)) } } /** Extract a `command` string from a tool call's parsed arguments, else ''. */ diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index d35cfe7993..bac95b7eed 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -13,6 +13,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** @@ -42,6 +43,7 @@ function writeHooks(dir: string, hooks: unknown): void { async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -182,6 +184,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -205,6 +208,7 @@ describe('hooks-codex bridge', () => { writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] }) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -227,12 +231,12 @@ describe('hooks-codex bridge', () => { it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { expect('default' in HooksCodex).toBe(false) expect(HooksCodex.name).toBe('hooks-codex') - expect(HooksCodex.inject).toEqual(['shell']) + expect(HooksCodex.inject).toEqual(['shell', 'sessionProjections']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(HooksCodex) as Record expect(unwrapped).toBe(HooksCodex) expect(unwrapped.name).toBe('hooks-codex') - expect(unwrapped.inject).toEqual(['shell']) + expect(unwrapped.inject).toEqual(['shell', 'sessionProjections']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 53742e2e96..214b8484ea 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -13,6 +13,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' const testToolSignal = new AbortController().signal @@ -31,6 +32,7 @@ type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string } async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) @@ -314,6 +316,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -624,6 +627,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5826f75f75..d1c57e6738 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: 607cd4e4176631b64daf4a298b5d86a75ccdce68 -README.zh.md: cdfb5aa65b3ad5b00596487aae6c99e2f9d4e433 +README.md: febc0933270dfe03301bd13552d433bf141e6ce3 +README.zh.md: 9bd9a108b26c1c00257cbf307332b0e63da83c2d diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 607cd4e417..febc093327 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -26,7 +26,7 @@ Question responses are validated against their pending request before the first `session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compaction/summary` record on the same page as the replacement that cites it. -`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. The gateway owns two units: `sessionListMetadata` caches the monotonic blank-to-nonblank transition and latest human prompt time used by `session.list`, while `imageLimits` publishes the attachments config enforced at prompt admission as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the latter activates only while both the registry and the attachments service are composed. +`session.history`'s tail page (`beforeSeq` absent) additionally carries a `projections` block — the watermark snapshot of every client-visible unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). `ApiProxyService` requires the registry; only the lower-level `createApiProxy` factory may omit the block and frames for isolated tests and diagnostics. The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block. The gateway owns two units: `sessionListMetadata` caches the monotonic blank-to-nonblank transition and latest human prompt time used by `session.list`, while `imageLimits` publishes the attachments config enforced at prompt admission as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the latter activates only while both the registry and the attachments service are composed. Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). `HEAD` runs the same root preparation and returns its status and headers without a response body, so browser clients can detect pre-stream failures before handing the GET to the native download manager. Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cdfb5aa65b..9bd9a108b2 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compaction/summary` 记录与引用它的替换留在同一页。 -`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。网关拥有两个单元:`sessionListMetadata` 缓存用于 `session.list` 的单调 blank→nonblank 转换与最新真人 prompt 时间;`imageLimits` 则把 prompt 准入时执行的 attachments 配置作为每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限,后者仅在注册表与 attachments 服务同时组合时激活。 +`session.history` 的尾页(不带 `beforeSeq`)额外携带一个 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个客户端可见单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。`ApiProxyService` 要求该注册表;只有较低层的 `createApiProxy` factory 可在隔离测试和诊断中省略投影块与帧。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块。网关拥有两个单元:`sessionListMetadata` 缓存用于 `session.list` 的单调 blank→nonblank 转换与最新真人 prompt 时间;`imageLimits` 则把 prompt 准入时执行的 attachments 配置作为每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限,后者仅在注册表与 attachments 服务同时组合时激活。 会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。`HEAD` 会执行相同的根工件准备,并在没有响应 body 的情况下返回状态与响应头,使浏览器 Client 可以在把 GET 交给原生下载管理器前发现流式传输前的失败。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 946e27ded3..8895834eae 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -77,12 +77,14 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 97ce024ec0..05f6485d44 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -883,15 +883,6 @@ function subagentPromptError( return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} }) } -/** Stable RPC face of the missing projections capability, shared by every catalog read path. */ -function projectionsUnavailableError(): RpcError { - return { - code: 'internal', - message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - details: {}, - } -} - /** Verify one address and mode against the complete direct-child catalog. */ async function catalogChild( ctx: Context, @@ -928,9 +919,6 @@ async function catalogChild( if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) { return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } } } - if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { - return { error: projectionsUnavailableError() } - } return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } } } } @@ -1370,31 +1358,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // the signal fired — never invoked, entry pending forever, zombie frame // on every mux replay. Settle synchronously instead of publishing. if (req.signal?.aborted === true) return Promise.resolve('cancelled') - // The audit pair `approval/asked` is already appended by the service + // The audit pair `approval/asked` is already folded by the service // before dispatch, but dispatch rides a microtask: parallel tool calls - // can append several asked events before any answerer runs. THIS - // request's event is therefore the newest asked event that is still - // undecided, unclaimed by another pending entry, and — when the ask - // names a call — carries the same callId. - const events = req.agent.session.events + // can add several pending records before any answerer runs. THIS + // request's record is therefore the newest one that is still unclaimed + // by another pending entry and — when the ask names a call — carries + // the same callId. + const pending = ctx.get('sessionProjections')?.stateOf(req.agent.session, 'approvalPending') const claimed = new Set() for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId) - const decided = new Set() let approvalId: ApprovalRequestId | undefined - for (let i = events.length - 1; i >= 0; i -= 1) { - const event = events[i] as SessionEvent - if (event.type === 'approval/decided') { - decided.add(event.data.id) - } else if (event.type === 'approval/asked') { - if (decided.has(event.data.id) || claimed.has(event.data.id)) continue + let bestSeq = -1 + if (pending !== undefined) { + for (const [id, record] of Object.entries(pending)) { + if (claimed.has(id as ApprovalRequestId)) continue // Symmetric pairing: a callId-bearing ask only takes its own call's // record, and a callId-less ask only takes a callId-less record — // so neither shape can steal the other's audit id under parallel // asks. (Today every producer — the tool executor — passes callId; // the callId-less arm guards any future non-tool asker.) - if ((req.callId ?? null) !== (event.data.callId ?? null)) continue - approvalId = event.data.id - break + if ((req.callId ?? null) !== record.callId) continue + if (record.seq > bestSeq) { + bestSeq = record.seq + approvalId = id as ApprovalRequestId + } } } // No asked event means the request bypassed the service's audit path — @@ -2570,9 +2557,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') { - return err(request, projectionsUnavailableError()) - } return err(request, { code: 'internal', message: 'subagent catalog read failed', diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index ac6c770801..c8b50e2c01 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -68,8 +68,8 @@ export interface Config { */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ - 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery', - 'tools', 'userQuestions', 'workspaceRegistry', + 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'sessionProjections', + 'subagents', 'sessionQuery', 'tools', 'userQuestions', 'workspaceRegistry', ] static Config: z = z.object({ diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 88ab5ede42..823f638fe9 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -11,6 +11,8 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserQuestionService from '@deepseek-ai/dsh-user-questions' import ApprovalService from '@deepseek-ai/dsh-user-approval' @@ -23,6 +25,10 @@ import { createApiProxy } from '../src/api-proxy.ts' async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + // No loop in this bench: register the turnBoundary unit so the approval + // service's turn-enclosure gate sees the open turn. + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) @@ -211,6 +217,8 @@ describe('approval pending registry', () => { // effect while an ask is still pending. const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 28eaae1b0b..36c131ebe2 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -30,6 +31,7 @@ function request

(payload: P): RpcRequest

{ async function composed(withTitles = true): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) if (withTitles) { diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index a11e2a0416..ec3df497d7 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -233,33 +233,6 @@ describe('subagent gateway', () => { expect(inspect).not.toHaveBeenCalled() }) - it('maps the missing projections capability to one wire face on list, history, and prompt', async () => { - const listError = () => new SubagentError( - 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', - ) - const expected = { - code: 'internal', - message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - } - - const list = bench({ listError: listError() }) - expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result) - .toMatchObject({ ok: false, error: expected }) - - const history = bench({ listError: listError() }) - expect((await history.api.subagents.history(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', - }))).result).toMatchObject({ ok: false, error: expected }) - expect(history.inspect).not.toHaveBeenCalled() - - const prompt = bench({ listError: listError() }) - expect((await prompt.api.subagents.prompt(request({ - parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [], - }), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected }) - expect(prompt.followup).not.toHaveBeenCalled() - }) - it('routes human content through the exact live parent with rpc attribution', async () => { const { api, parent, followup } = bench() const content = [{ type: 'text' as const, text: '继续' }] diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml index 7e7359526c..072babf07b 100644 --- a/packages/interaction/permission-presets/README.i18n.yaml +++ b/packages/interaction/permission-presets/README.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 packages/interaction/permission-presets/README.md -README.md: 2b671f9e6e835c529453dc7d4ca7bc2eff01f2b6 -README.zh.md: 686138c1f2b0b3770771d0e6d6a785ed17cc8621 +README.md: a59b10809c4c93cc5d4b4c3715a17b4661f9e9fe +README.zh.md: 2e215ca7785c1b08f1bb4c8607aa5e34cf8deb9b diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md index 2b671f9e6e..a59b10809c 100644 --- a/packages/interaction/permission-presets/README.md +++ b/packages/interaction/permission-presets/README.md @@ -4,13 +4,13 @@ English | [中文](README.zh.md) User-facing permission presets through `ctx.permissionPresets` ([`PermissionPresetService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs. -`set(session, name)` records a changed selection in a log-only `permissionPresets/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. +`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(session)` reads the required `permissions` projection state, prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. The service owns the `permissionPresets` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permissionPresets/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent. The service requires a confining `ctx.shell` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). -Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permissionPresets` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. +The required `permissions` session-projection unit folds the three whole-value knob events and exposes the full knob state to host readers through `stateOf()`; its client view is the select over composition defaults, with table options plus a current-only `custom`. The optional `/permissionPresets` command activates when `ctx.commands` is composed; bare invocation reports the current preset and table, and a preset argument switches through `set`. ## Model Experience diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md index 686138c1f2..2e215ca778 100644 --- a/packages/interaction/permission-presets/README.zh.md +++ b/packages/interaction/permission-presets/README.zh.md @@ -4,13 +4,13 @@ 通过 `ctx.permissionPresets`([`PermissionPresetService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 -`set(session, name)` 会先在仅写日志的 `permissionPresets/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 +`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(session)` 读取必需的 `permissions` 投影状态,优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。 该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 -两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permissionPresets` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 +必需的 `permissions` 会话投影单元折叠三个全量值 knob 事件,并通过 `stateOf()` 向 host 读取方提供完整 knob 状态;其客户端视图是基于组合默认值的选择器,包含表内选项与仅作当前值的 `custom`。可选的 `/permissionPresets` 命令在组合了 `ctx.commands` 时激活;不带参数调用会报告当前预设与表,预设参数经 `set` 切换。 ## 模型体验 diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index 7f06f6e8e6..d871e819c4 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -5,7 +5,7 @@ * and replay keep reading their knob folds. The preset event preserves user * intent when two presets share a bundle. The read side ships as the * `permissions` session projection; the write side ships as the - * `/permission` command — both optional children over the same service. + * `/permission` command. * * @module dsh-permission-presets */ @@ -15,22 +15,18 @@ import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' -import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { SANDBOX_MODES, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' // Side-effect type import: declaration-merges `ctx.shell` (the capability fact // `sandboxMode` this service reads), without a value dependency on the seam. import type {} from '@deepseek-ai/dsh-shell' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' -import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { APPROVAL_POLICIES, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' -// Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children. +// Type-only: resolves ctx.sessionProjections and the optional command child. import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-commands' -import type { PermissionSelect, PresetOption } from './types.ts' +import type { KnobState, PermissionSelect, PresetOption } from './types.ts' -// The `permissions` projection-key declaration lives in src/types.ts (its one -// home); this re-export projects the type face onto the package root AND -// keeps the module edge in the emitted index.d.ts, so aggregate programs -// consuming the declarations still receive the SessionProjectionMap merge. export type * from './types.ts' declare module '@deepseek-ai/cordis' { @@ -44,7 +40,7 @@ declare module '@deepseek-ai/dsh-session/types' { /** * Records the selected preset as durable, log-only user intent. The knob * events follow in the same turn and control execution; this event stays - * out of the model transcript and lets {@link effectivePermissionPreset} + * out of the model transcript and lets the permission projection unit * preserve a selection when bundles match. */ 'permission/preset': { preset: string } @@ -72,48 +68,19 @@ export const CUSTOM_PRESET = 'custom' /** Settings namespace carrying the default for future sessions. */ export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission') -/** - * Fold the last selected preset from the durable log; replay needs no catch-up - * state. - * @param events - session events in log order; other event types are ignored. - * @returns the last selected preset, or undefined when none was recorded. - */ -export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] as SessionEvent - if (event.type === 'permission/preset') return event.data.preset - } - return undefined -} +const sandboxField = zod.union([ + zod.literal('read-only'), + zod.literal('workspace-write'), + zod.literal('danger-full-access'), +]).nullable() -/** - * The projection unit's state: the last seen value of each knob event, null - * before an override (composition defaults apply at view time). Plain JSON - * (persisted-cache precondition). - */ -export interface KnobState { - /** Last `permission/preset` payload, or null. */ - preset: string | null - /** Last `sandbox/mode` payload, or null. */ - sandbox: SandboxMode | null - /** Last `approval/policy` payload, or null. */ - approval: ApprovalPolicy | null -} - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - permissions: KnobState - } -} +const approvalField = zod.union([zod.literal('ask'), zod.literal('never')]).nullable() const knobStateSchema: zod.ZodType = zod.object({ + /** Last `permission/preset` payload, or null. */ preset: zod.string().nullable(), - sandbox: zod.union([ - zod.literal('read-only'), - zod.literal('workspace-write'), - zod.literal('danger-full-access'), - ]).nullable(), - approval: zod.union([zod.literal('ask'), zod.literal('never')]).nullable(), + sandbox: sandboxField, + approval: approvalField, }) /** State for the empty log: every knob at its composition default. */ @@ -139,13 +106,6 @@ export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState } } -/** Whole-log knob fold (the cold-read parallel of {@link applyKnobEvent}). */ -function foldKnobs(events: readonly SessionEvent[]): KnobState { - let state = EMPTY_KNOBS - for (const event of events) state = applyKnobEvent(state, event) - return state -} - /** User setting resolved when a new session receives its initial permission. */ export interface PermissionSettings { /** Preset pinned into a newly created session. */ @@ -193,7 +153,7 @@ export class PermissionPresetService extends Service { defaultPreset: z.string(), }) - static inject = ['shell', 'approval', 'sessions'] + static inject = ['shell', 'approval', 'sessions', 'sessionProjections'] private readonly presets: Record private defaultSettings: () => PermissionSettings @@ -240,10 +200,6 @@ export class PermissionPresetService extends Service { this.pinInitialPermission(session) } - // The permissions projection unit: fold the three whole-value knob - // events; view derives the select over the composition defaults this - // service already owns. The unit child activates only when a projection - // registry is composed (headless assemblies stay unaffected). // zod `.optional()` types the key `string | undefined` while the domain // says `description?: string`; on the JSON wire the two serialize // identically (absent), so the cast records exactly that @@ -256,15 +212,13 @@ export class PermissionPresetService extends Service { })), currentValue: zod.string().min(1), }) as unknown as zod.ZodType - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'permissions', KnobState>({ - key: 'permissions', - stateSchema: knobStateSchema, - init: () => EMPTY_KNOBS, - apply: applyKnobEvent, - wire: { viewSchema: selectSchema, view: state => this.selectFor(state) }, - stateVersion: 1, - }) + ctx.sessionProjections.register({ + key: 'permissions', + stateVersion: 1, + stateSchema: knobStateSchema, + init: () => EMPTY_KNOBS, + apply: applyKnobEvent, + wire: { viewSchema: selectSchema, view: state => this.selectFor(state) }, }) // The /permission command: the one write path a web client uses (the @@ -281,7 +235,7 @@ export class PermissionPresetService extends Service { handler: ({ agent, rawInput }) => { const name = rawInput.trim() if (name === '') { - return { kind: 'success', text: `current preset ${this.current(agent.session.events)} (available: ${this.names.join(', ')})` } + return { kind: 'success', text: `current preset ${this.current(agent.session)} (available: ${this.names.join(', ')})` } } if (!this.names.includes(name)) { return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` } @@ -310,15 +264,19 @@ export class PermissionPresetService extends Service { return this.defaultSettings().defaultPreset } + private knobs(session: Session): KnobState { + return this.ctx.sessionProjections.stateOf(session, 'permissions') ?? EMPTY_KNOBS + } + /** * Resolve the preset matching the effective knob values. A still-matching * last selection wins shared-bundle ties; otherwise the first table match * wins, or {@link CUSTOM_PRESET} when no entry matches. - * @param events - the session's events in log order. + * @param session - the session whose knob state is read. * @returns the effective preset name, or `custom` when nothing matches. */ - current(events: readonly SessionEvent[]): string { - return this.derive(foldKnobs(events)) + current(session: Session): string { + return this.derive(this.knobs(session)) } /** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */ @@ -395,14 +353,14 @@ export class PermissionPresetService extends Service { /** Apply one preset with the caller-selected live or initialization policy writer. */ private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void { const spec = this.resolve(name) - if (this.current(session.events) !== name) { + if (this.current(session) !== name) { session.append('permission/preset', { preset: name }) } - const events = session.events - if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.shell.sandboxMode)) { + const knobs = this.knobs(session) + if (spec.sandbox !== (knobs.sandbox ?? this.ctx.shell.sandboxMode)) { setSandboxMode(session, spec.sandbox) } - if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) { + if (spec.approval !== (knobs.approval ?? this.ctx.approval.config.policy ?? 'ask')) { setApproval(spec.approval) } } @@ -414,12 +372,12 @@ export class PermissionPresetService extends Service { * the missing durable facts. */ private pinInitialPermission(session: Session): void { - const events = session.events - const selected = effectivePermissionPreset(events) - const sandbox = effectiveSandboxMode(events) - const approval = effectiveApprovalPolicy(events) - const seeded = events.some(event => event.type === 'session/end-seed') - if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) { + const state = this.knobs(session) + const selected = state.preset + const sandbox = state.sandbox + const approval = state.approval + const seeded = session.events.some(event => event.type === 'session/end-seed') + if (selected === null && sandbox === null && approval === null && !seeded) { const name = this.defaultPreset const spec = this.resolve(name) session.append('permission/preset', { preset: name }) @@ -428,19 +386,14 @@ export class PermissionPresetService extends Service { return } - const state: KnobState = { - preset: selected ?? null, - sandbox: sandbox ?? null, - approval: approval ?? null, - } const effective = this.derive(state) - if (selected === undefined && effective !== CUSTOM_PRESET) { + if (selected === null && effective !== CUSTOM_PRESET) { session.append('permission/preset', { preset: effective }) } - if (sandbox === undefined) { + if (sandbox === null) { setSandboxMode(session, this.ctx.shell.sandboxMode as SandboxMode) } - if (approval === undefined) { + if (approval === null) { setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask') } } diff --git a/packages/interaction/permission-presets/src/types.ts b/packages/interaction/permission-presets/src/types.ts index a978e3b5e8..2c9ef57acc 100644 --- a/packages/interaction/permission-presets/src/types.ts +++ b/packages/interaction/permission-presets/src/types.ts @@ -2,13 +2,15 @@ * Pure types of the permission domain: the ONE home of the `permissions` * projection-key declaration plus its payload types, free of this package's * host-side value imports (cordis, schemastery). Two namespace projections - * serve it — the package root re-export for host consumers, `./client` (the - * browser half-entry's re-export) for client aggregates — with zero content - * duplication. + * serve it — the package root re-export for host consumers and `./client` for + * client aggregates — with zero content duplication. * * @module @deepseek-ai/dsh-permission-presets/types */ +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' + /** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */ export interface PresetOption { /** Stable option value: the table key, or `custom`. */ @@ -31,7 +33,21 @@ export interface PermissionSelect { currentValue: string } +/** Latest logged permission overrides. */ +export interface KnobState { + /** Last `permission/preset` payload, or null. */ + preset: string | null + /** Last `sandbox/mode` payload, or null. */ + sandbox: SandboxMode | null + /** Last `approval/policy` payload, or null. */ + approval: ApprovalPolicy | null +} + declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Latest logged permission overrides. */ + permissions: KnobState + } interface SessionProjectionMap { /** * The session's permission select, folded from the three whole-value diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 1f0e8760b8..251a917dde 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import PermissionPresetService, { - CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE, + CUSTOM_PRESET, PERMISSION_SETTINGS_NAMESPACE, } from '@deepseek-ai/dsh-permission-presets' import type { Config } from '@deepseek-ai/dsh-permission-presets' import { SettingsProvider } from '@deepseek-ai/dsh-settings' @@ -32,6 +33,7 @@ async function mounted(options: { } = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) ctx.provide('shell', { sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write', resolve() { throw new Error('permission tests do not execute bash') }, @@ -50,6 +52,7 @@ function freshSession(id: string): Session { async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(MemorySettings) ctx.provide('shell', { sandboxMode: 'workspace-write', @@ -64,16 +67,18 @@ async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefi return ctx } -describe('effectivePermissionPreset', () => { - it('folds to the last event, or undefined without one', () => { +describe('permission preset fold', () => { + it('folds the latest preset selection and steps over unrelated events', async () => { + const ctx = await mounted() const session = freshSession('sess-fold') - expect(effectivePermissionPreset(session.events)).toBeUndefined() + const presetOf = () => ctx.sessionProjections.stateOf(session, 'permissions')?.preset ?? null + expect(presetOf()).toBeNull() session.append('permission/preset', { preset: 'danger-full-access' }) session.append('permission/preset', { preset: 'workspace-write' }) - expect(effectivePermissionPreset(session.events)).toBe('workspace-write') - // The backward scan steps over non-preset events to the latest selection. + expect(presetOf()).toBe('workspace-write') + // The knob fold steps over non-preset events to the latest selection. session.append('sandbox/mode', { mode: 'read-only' }) - expect(effectivePermissionPreset(session.events)).toBe('workspace-write') + expect(presetOf()).toBe('workspace-write') }) }) @@ -88,18 +93,18 @@ describe('PermissionPresetService', () => { it('current() derives from the effective knobs: composition defaults hit workspace-write, a switch hits its preset', async () => { const ctx = await mounted() const session = freshSession('sess-current') - expect(ctx.permissionPresets.current(session.events)).toBe('workspace-write') + expect(ctx.permissionPresets.current(session)).toBe('workspace-write') ctx.permissionPresets.set(session, 'danger-full-access') - expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access') + expect(ctx.permissionPresets.current(session)).toBe('danger-full-access') }) it('a knob state matching no table entry derives custom — a state, not an error', async () => { const ctx = await mounted() const session = freshSession('sess-custom') session.append('sandbox/mode', { mode: 'read-only' }) - expect(ctx.permissionPresets.current(session.events)).toBe(CUSTOM_PRESET) + expect(ctx.permissionPresets.current(session)).toBe(CUSTOM_PRESET) ctx.permissionPresets.set(session, 'danger-full-access') - expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access') + expect(ctx.permissionPresets.current(session)).toBe('danger-full-access') expect(() => ctx.permissionPresets.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/) }) @@ -109,7 +114,7 @@ describe('PermissionPresetService', () => { config: { defaultPreset: 'workspace-write' }, }) const session = freshSession('sess-defaults-custom') - expect(ctx.permissionPresets.current(session.events)).toBe(CUSTOM_PRESET) + expect(ctx.permissionPresets.current(session)).toBe(CUSTOM_PRESET) }) it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => { @@ -120,10 +125,10 @@ describe('PermissionPresetService', () => { } } }) const session = freshSession('sess-tie') ctx.permissionPresets.set(session, 'agentish') - expect(ctx.permissionPresets.current(session.events)).toBe('agentish') + expect(ctx.permissionPresets.current(session)).toBe('agentish') session.append('approval/policy', { policy: 'never' }) session.append('sandbox/mode', { mode: 'danger-full-access' }) - expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access') + expect(ctx.permissionPresets.current(session)).toBe('danger-full-access') }) it('set() writes through: one preset event plus both knob events', async () => { @@ -188,7 +193,7 @@ describe('PermissionPresetService', () => { const session = freshSession('sess-standin') ctx.permissionPresets.set(session, 'workspace-write') expect(session.events).toHaveLength(0) - expect(ctx.permissionPresets.current(session.events)).toBe('workspace-write') + expect(ctx.permissionPresets.current(session)).toBe('workspace-write') }) }) @@ -207,8 +212,8 @@ describe('new-session default', () => { }) expect(ctx.permissionPresets.defaultPreset).toBe('danger-full-access') const second = ctx.sessions.create(SessionId('second')) - expect(ctx.permissionPresets.current(first.events)).toBe('workspace-write') - expect(ctx.permissionPresets.current(second.events)).toBe('danger-full-access') + expect(ctx.permissionPresets.current(first)).toBe('workspace-write') + expect(ctx.permissionPresets.current(second)).toBe('danger-full-access') expect(second.events.map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -223,7 +228,7 @@ describe('new-session default', () => { legacy.append('turn/start', { turn: 1 }) legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events }) - expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write') + expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') expect(resumed.events.slice(-3).map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -235,7 +240,7 @@ describe('new-session default', () => { defaultPreset: 'danger-full-access', }) const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] }) - expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write') + expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') expect(resumed.events.map(event => event.type)).toEqual([ 'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy', ]) @@ -244,6 +249,7 @@ describe('new-session default', () => { it('pins sessions that already exist when the service remounts', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) ctx.provide('shell', { sandboxMode: 'workspace-write', resolve() { throw new Error('permission tests do not execute bash') }, @@ -258,7 +264,7 @@ describe('new-session default', () => { expect(existing.events.map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) - expect(ctx.permissionPresets.current(existing.events)).toBe('workspace-write') + expect(ctx.permissionPresets.current(existing)).toBe('workspace-write') }) it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => { @@ -276,7 +282,7 @@ describe('new-session default', () => { custom.append('sandbox/mode', { mode: 'read-only' }) custom.append('approval/policy', { policy: 'never' }) const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events }) - expect(ctx.permissionPresets.current(unmatched.events)).toBe(CUSTOM_PRESET) + expect(ctx.permissionPresets.current(unmatched)).toBe(CUSTOM_PRESET) expect(unmatched.events.at(-1)?.type).toBe('session/end-seed') }) diff --git a/packages/interaction/permission-presets/tests/projection.spec.ts b/packages/interaction/permission-presets/tests/projection.spec.ts index f9bba33958..d9fa287b6b 100644 --- a/packages/interaction/permission-presets/tests/projection.spec.ts +++ b/packages/interaction/permission-presets/tests/projection.spec.ts @@ -60,10 +60,9 @@ describe('permissions projection unit', () => { changes.push({ key, value, seq }) }) ctx.permissionPresets.set(session, 'danger-full-access') - // set() appends preset + sandbox/mode + approval/policy: three knob transitions. - expect(changes).toHaveLength(3) - expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } }) - // Unrelated event: same-reference apply, no notification. + const permissionChanges = changes.filter(change => change.key === 'permissions') + expect(permissionChanges).toHaveLength(3) + expect(permissionChanges.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } }) session.append('turn/start', { turn: 1 }) expect(changes).toHaveLength(3) }) @@ -80,7 +79,8 @@ describe('permissions projection unit', () => { const { ctx, session } = await harness({ withPermission: false }) expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false) const fiber = await ctx.plugin(PermissionPresetService, {}) - expect(ctx.sessionProjections.snapshot(session).values.permissions).toMatchObject({ currentValue: 'workspace-write' }) + expect(ctx.sessionProjections.snapshot(session).values.permissions) + .toMatchObject({ currentValue: 'workspace-write' }) await fiber.dispose() expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false) }) @@ -92,7 +92,7 @@ describe('/permission command', () => { const { agent, inject } = await agentFor(ctx, session) const execution = await ctx.commands.execute(agent, '/permission danger-full-access', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' }) - expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access') + expect(ctx.permissionPresets.current(session)).toBe('danger-full-access') expect(inject.mock.calls[0]?.[0]).toMatchObject({ content: [{ type: 'text', diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 06f72c1e2f..3eb84ad80e 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -44,10 +44,12 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -57,6 +59,8 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^" } } diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index b0618f6b3d..51712ecc1c 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -7,11 +7,13 @@ import { randomUUID } from 'node:crypto' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { z as zod } from 'zod' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { @@ -31,6 +33,33 @@ declare module '@deepseek-ai/cordis' { } } +const approvalPolicyStateSchema = zod.union([ + zod.literal('ask'), + zod.literal('never'), +]).nullable() + +type ApprovalPolicyState = zod.infer + +const approvalPendingStateSchema = zod.record(zod.string(), zod.object({ + callId: zod.string().nullable(), + seq: zod.number().int().nonnegative(), +})) + +type ApprovalPendingState = zod.infer + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Last logged approval-policy override, or null before one (composition default applies at read time). */ + approvalPolicy: ApprovalPolicyState + /** + * Outstanding `approval/asked` records not yet resolved by their + * `approval/decided` — the audit-pair pending set (the carrier's + * approval/request correlation reads it). + */ + approvalPending: ApprovalPendingState + } +} + declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** @@ -60,7 +89,8 @@ declare module '@deepseek-ai/dsh-session/types' { * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override (folded by the approvalPolicy + * projection unit). * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ @@ -101,38 +131,6 @@ const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions t /** Model-facing statement for an interactive policy that may still fail closed. */ const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.' -/** - * The session's approval-policy override: the last `approval/policy` event in - * the log, or undefined when the session never switched (callers apply the - * plugin's configured default). The pure fold — resume needs no catch-up - * machinery because replaying the log IS the state. - * @param events - session events in log order (other event types are skipped). - * @returns the policy of the last switch event, or undefined without one. - */ -export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] as SessionEvent - if (event.type === 'approval/policy') return event.data.policy - } - return undefined -} - -/** - * Whether the log currently sits inside an open turn (a `turn/start` not yet - * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. - * The audit pair must be turn-enclosed: the turn is the durable log's - * commit/replay boundary, so a bare event appended between turns is - * indistinguishable from a crash tail and silently dropped on reload. - */ -function hasOpenTurn(events: readonly SessionEvent[]): boolean { - for (let index = events.length - 1; index >= 0; index -= 1) { - const type = (events[index] as SessionEvent).type - if (type === 'turn/start') return true - if (type === 'turn/end') return false - } - return false -} - /** * Append the sole durable representation of a session policy override. Invalid * values throw before the log changes; consumers fold the new value on each read. @@ -194,11 +192,39 @@ export class ApprovalService extends Service { policy: z.union(['ask', 'never'] as const).default('ask'), }) + static inject = ['sessionProjections'] + constructor(ctx: Context, public config: Config) { super(ctx, 'approval') const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session) + ctx.sessionProjections.register({ + key: 'approvalPolicy', + stateVersion: 1, + stateSchema: approvalPolicyStateSchema, + init: () => null, + apply: (state, event) => (event.type === 'approval/policy' ? event.data.policy : state), + }) + ctx.sessionProjections.register({ + key: 'approvalPending', + stateVersion: 1, + stateSchema: approvalPendingStateSchema, + init: () => ({}), + apply: (state, event) => { + if (event.type === 'approval/asked') { + if (state[event.data.id] !== undefined) return state + return { ...state, [event.data.id]: { callId: event.data.callId ?? null, seq: event.seq } } + } + if (event.type === 'approval/decided') { + if (state[event.data.id] === undefined) return state + const { [event.data.id]: _decided, ...next } = state + return next + } + return state + }, + }) + // The complete current value travels after retained history, so switching // policy does not rewrite the stable system-prompt cache prefix. ctx.inject(['systemPrompt'], (scope: Context) => { @@ -256,7 +282,7 @@ export class ApprovalService extends Service { */ async request(req: ApprovalRequest): Promise { const session = req.agent.session - if (!hasOpenTurn(session.events)) { + if ((this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.openTurn ?? null) === null) { throw new Error( 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair ' + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). ' @@ -276,7 +302,7 @@ export class ApprovalService extends Service { } /** - * The session's effective policy: its own `approval/policy` fold, else the + * The session's effective policy: its projected `approval/policy` state, else the * configured default (the schema already defaulted an omitted policy to * `'ask'`; the `??` only narrows the optional-input TYPE). * @param session - the exact accepted session whose policy applies. @@ -287,12 +313,12 @@ export class ApprovalService extends Service { } /** - * Read the session override without applying the configured default. + * Read the projected session override without applying the configured default. * @param session - session whose log supplies the override. * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined { - return effectiveApprovalPolicy(session.events) + return this.ctx.sessionProjections.stateOf(session, 'approvalPolicy') ?? undefined } /** diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index 3271ecc7a5..906551f229 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -6,8 +6,14 @@ import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService, { ApprovalOutcome, ApprovalRequest, ApprovalRequestId, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' + +function registerTurnBoundary(ctx: Context): void { + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +} /** * A minimal Agent stand-in — the service only reaches `agent.session.append` @@ -15,7 +21,10 @@ import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPol * turn-enclosure precondition); pass `seed` to stage idle/closed logs. * Returns the recorded audit appends alongside the fake. */ -function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record }> } { +function fakeAgent(seed: Array<{ type: string; data?: Record; seq?: number }> = [ + { type: 'turn/start', seq: 0, data: { turn: 1 } }, + { type: 'user/message', seq: 1, data: {} }, +]): { agent: Agent; appended: Array<{ type: string; data: Record }> } { const appended: Array<{ type: string; data: Record }> = [] const agent = { session: { @@ -31,6 +40,8 @@ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { ty async function mounted(): Promise { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) return ctx } @@ -50,7 +61,10 @@ describe('ApprovalService.request', () => { it('throws between turns — a closed turn does not satisfy the enclosure precondition', async () => { const ctx = await mounted() - const { agent, appended } = fakeAgent([{ type: 'turn/start' }, { type: 'turn/end' }]) + const { agent, appended } = fakeAgent([ + { type: 'turn/start', seq: 0, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, data: { turn: 1, reason: { kind: 'completed' } } }, + ]) await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/) expect(appended).toHaveLength(0) @@ -116,6 +130,8 @@ describe('ApprovalService.request', () => { it('contains an approval/asked observer throw after append and still completes the pair', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const session = ctx.sessions.create(SessionId('asked-observer-throw')) session.append('turn/start', { turn: 1 }) @@ -139,6 +155,8 @@ describe('ApprovalService.request', () => { it('contains an approval/decided observer throw after append and still resolves', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const session = ctx.sessions.create(SessionId('decided-observer-throw')) session.append('turn/start', { turn: 1 }) @@ -164,7 +182,7 @@ describe('ApprovalService.request', () => { const failure = new Error('append failed before log growth') const agent = { session: { - events: [{ type: 'turn/start' }], + events: [{ type: 'turn/start', seq: 0, data: { turn: 1 } }], append: () => { throw failure }, }, } as unknown as Agent @@ -364,15 +382,45 @@ describe('approval policy (the approval/policy fold)', () => { return { agent, session } } - it('folds to the last event, or undefined without one', () => { + it('folds to the last event, or undefined without one', async () => { + const ctx = await mounted() const { session } = sessionAgent('sess-fold') - expect(effectiveApprovalPolicy(session.events)).toBeUndefined() + expect(ctx.approval.overrideOf(session)).toBeUndefined() setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') - expect(effectiveApprovalPolicy(session.events)).toBe('ask') + expect(ctx.approval.overrideOf(session)).toBe('ask') expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) }) + it('folds the pending audit pair without double-counting duplicates', async () => { + const ctx = await mounted() + const { session } = sessionAgent('sess-pending') + const asked = session.append('approval/asked', { + id: ApprovalRequestId('pending-1'), + callId: CallId('call-1'), + toolName: 'echo', + }) + session.append('approval/asked', { + id: ApprovalRequestId('pending-1'), + callId: CallId('call-1'), + toolName: 'echo', + }) + const pending = ctx.sessionProjections.snapshot(session).values.approvalPending ?? {} + expect(pending).toEqual({ + 'pending-1': { callId: 'call-1', seq: asked.seq }, + }) + }) + + it('ignores a decided event without a pending asked record', async () => { + const ctx = await mounted() + const { session } = sessionAgent('sess-unknown-decide') + session.append('approval/decided', { + id: ApprovalRequestId('never-asked'), + outcome: 'rejected', + }) + expect(ctx.sessionProjections.snapshot(session).values.approvalPending).toEqual({}) + }) + it('rejects a policy outside the closed vocabulary before appending', () => { const append = vi.fn() const session = { append } as unknown as Session @@ -386,6 +434,8 @@ describe('approval policy (the approval/policy fold)', () => { // Direct construction bypasses the plugin schema (the SystemPrompt-test // precedent for covering a defaulted Config field's type-narrowing ??). const ctx = new Context() + new SessionProjectionRegistry(ctx) + registerTurnBoundary(ctx) const service = new ApprovalService(ctx, {}) const { agent } = sessionAgent('sess-bare-config') ctx.on('approval/request', () => Promise.resolve('allowed-once')) @@ -394,6 +444,8 @@ describe('approval policy (the approval/policy fold)', () => { it('contains an answerer that throws SYNCHRONOUSLY as unavailable', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const { agent } = sessionAgent('sess-syncthrow') ctx.on('approval/request', () => { throw new Error('sync bug') }) @@ -402,6 +454,8 @@ describe('approval policy (the approval/policy fold)', () => { it('a never config rejects deterministically without consulting any answerer', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) const consulted = vi.fn() ctx.on('approval/request', (_req, next) => { consulted(); return next() }) @@ -416,6 +470,8 @@ describe('approval policy (the approval/policy fold)', () => { it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => { const ctx = new Context() ctx.on('approval/request', () => Promise.resolve('allowed-once')) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) const { agent } = sessionAgent('sess-gate-2') await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') @@ -426,6 +482,8 @@ describe('approval policy (the approval/policy fold)', () => { // service could register — which is exactly why the 'never' decision lives inside request() // instead. This eager grant would bypass a listener-based gate and therefore must never run. const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) const consulted = vi.fn() ctx.on('approval/request', () => { consulted(); return Promise.resolve('allowed-once') }, { prepend: true }) @@ -437,6 +495,8 @@ describe('approval policy (the approval/policy fold)', () => { it('a session override outranks the configured default, in both directions', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService, { policy: 'never' }) ctx.on('approval/request', () => Promise.resolve('allowed-once')) const { agent, session } = sessionAgent('sess-gate-3') @@ -450,6 +510,8 @@ describe('approval policy (the approval/policy fold)', () => { it('queues a live policy switch for the next model step', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-policy-notice') const inject = vi.fn() @@ -458,7 +520,7 @@ describe('approval policy (the approval/policy fold)', () => { ctx.approval.setPolicy(liveAgent, 'never') ctx.approval.setPolicy(liveAgent, 'never') - expect(effectiveApprovalPolicy(session.events)).toBe('never') + expect(ctx.approval.overrideOf(session)).toBe('never') expect(inject).toHaveBeenCalledOnce() expect(inject.mock.calls[0]?.[0]).toMatchObject({ content: [{ @@ -472,6 +534,8 @@ describe('approval policy (the approval/policy fold)', () => { it('contributes the complete current ask or never policy as cache-safe context', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const askAgent = sessionAgent('sess-sect-ask').agent const { agent: neverAgent, session } = sessionAgent('sess-sect-never') @@ -487,6 +551,8 @@ describe('approval policy (the approval/policy fold)', () => { it('reflects the latest durable switch in cache-safe context and stays byte-stable while unchanged', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) const { agent, session } = sessionAgent('sess-context-switch') const contextFor = async () => @@ -503,6 +569,8 @@ describe('approval policy (the approval/policy fold)', () => { it('disposes the runtime-context contribution with the service', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) const fiber = await ctx.plugin(ApprovalService) const { agent } = sessionAgent('sess-hmr-service-live') const contextFor = async () => diff --git a/packages/interaction/user-approval/tsconfig.json b/packages/interaction/user-approval/tsconfig.json index 2c8d26e1f6..7adfb948c7 100644 --- a/packages/interaction/user-approval/tsconfig.json +++ b/packages/interaction/user-approval/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 44386ce78b..27fcaf37ae 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -42,10 +42,12 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -64,6 +66,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 45db304059..95568bab0a 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -8,9 +8,10 @@ import { randomUUID } from 'node:crypto' import type { Context, Events } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { z as zod } from 'zod' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-projection' import { RetryId } from './brand.ts' import type { LlmRetryEventData } from './types.ts' @@ -18,7 +19,7 @@ export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' export { RetryId } from './brand.ts' export const name = 'llm-retry' -export const inject = ['agents'] +export const inject = ['agents', 'sessionProjections'] /** This policy executor has no config; providers own `retryPolicy`. */ export type Config = Readonly> @@ -75,6 +76,10 @@ function retryPolicyKey(policy: ResolvedRetryPolicy): string { ]) } +function retryStateKey(provider: string, policyKey: string): string { + return JSON.stringify([provider, policyKey]) +} + function cancellableDelay(delayMs: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.resolve(false) return new Promise((resolve) => { @@ -96,7 +101,39 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise + +const llmRetryStateSchema: zod.ZodType = zod.record(zod.string(), zod.object({ + retry: zod.number().int().nonnegative(), + retryId: zod.string(), +})) as unknown as zod.ZodType +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Retry state for the current step by provider and policy. */ + llmRetry: LlmRetryState + } +} + export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void { + ctx.sessionProjections.register({ + key: 'llmRetry', + stateVersion: 2, + stateSchema: llmRetryStateSchema, + init: () => ({}), + apply: (state, event) => { + if (event.type === 'step/start' || event.type === 'turn/end') return Object.keys(state).length === 0 ? state : {} + if (event.type !== 'llm/retry') return state + const key = retryStateKey(event.data.provider, event.data.policyKey) + const entry = state[key] + if (entry?.retry === event.data.retry && entry.retryId === event.data.retryId) return state + return { ...state, [key]: { retry: event.data.retry, retryId: event.data.retryId } } + }, + }) validateConfig(config) const random = internals.random ?? Math.random const lifetime = new AbortController() @@ -179,17 +216,12 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } const policyKey = retryPolicyKey(policy) - const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> => - event.type === 'llm/retry' - && event.data.turn === turn - && event.data.step === step - && event.data.provider === provider - && event.data.policyKey === policyKey, - ) - const previousRetry = priorPolicyRetry?.data.retry ?? 0 + const retryState = ctx.sessionProjections.stateOf(agent.session, 'llmRetry') + const previous = retryState?.[retryStateKey(provider, policyKey)] + const previousRetry = previous?.retry ?? 0 if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() const retry = previousRetry + 1 - const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID()) + const retryId = previous?.retryId ?? RetryId(randomUUID()) let delayMs: number if (failure.providerRetryAfterMs !== undefined && Number.isFinite(failure.providerRetryAfterMs) diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index 47d6d2a812..0c6c9c6b6b 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -4,7 +4,10 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import AgentRegistry from '@deepseek-ai/dsh-agent' import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' +import * as retry from '../src/index.ts' import { RetryId } from '@deepseek-ai/dsh-llm-retry' import { providerForOpenStep } from '../src/history.ts' @@ -58,6 +61,28 @@ const always = { failure, } +describe('llm-retry projection fold', () => { + it('keeps one retry count per dispatch key across duplicate records', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(retry) + const session = ctx.sessions.create(SessionId('retry-dup')) + session.append('llm/retry', { turn: 1, step: 1, ...normal }) + const first = ctx.sessionProjections.stateOf(session, 'llmRetry') + session.append('llm/retry', { turn: 1, step: 1, ...normal }) + const second = ctx.sessionProjections.stateOf(session, 'llmRetry') + expect(second).toBe(first) + expect(second).toEqual({ + '["mock","normal-policy"]': { + retry: 1, + retryId: 'normal-retry-chain', + }, + }) + }) +}) + describe('llm-retry invariants', () => { it('has no provider without the requested open step or a route marker', () => { expect(providerForOpenStep([], 1, 1)).toBeUndefined() diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 367e8f46f4..b4bef4c239 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -11,6 +11,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmRuntime, { createUserMessage, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import * as retry from '../src/index.ts' @@ -60,6 +61,7 @@ async function loadYaml(lines: readonly string[]): Promise { const modules = new Map([ ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], ['@deepseek-ai/dsh-agent', AgentRegistry], @@ -89,6 +91,7 @@ describe('real Loader composition', () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", "- name: '@deepseek-ai/dsh-agent'", diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index c2643a6d45..600700d9e3 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -12,6 +12,7 @@ import type { StreamChunk, } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -107,6 +108,7 @@ async function harness( const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -1025,14 +1027,20 @@ describe('provider-routed retry policy', () => { it('rejects retry policy configured on the executor instead of a provider', () => { expectTypeOf<{}>().toExtend() expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend() + const ctx = new Context() + // `apply` registers its projection unit first; the registry is a required + // injection, so the direct-apply path must carry it too. + new SessionProjectionRegistry(ctx) expect(() => { - retry.apply(new Context(), { retryPolicy: { mode: 'always' } } as unknown as retry.Config) + retry.apply(ctx, { retryPolicy: { mode: 'always' } } as unknown as retry.Config) }).toThrow(/retryPolicy belongs under each provider/) }) it('rejects unknown executor config', () => { + const ctx = new Context() + new SessionProjectionRegistry(ctx) expect(() => { - retry.apply(new Context(), { retryPolciy: {} } as unknown as retry.Config) + retry.apply(ctx, { retryPolciy: {} } as unknown as retry.Config) }).toThrow(/unknown key "retryPolciy"/) }) }) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index fb830f9868..7591f8fdf2 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -10,6 +10,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as Retry from '../src/index.ts' let context: Context | undefined @@ -37,6 +38,7 @@ async function harness( vi.stubEnv('DEEPSEEK_API_KEY', 'mock-key') const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(LlmDeepSeek, { baseURL, streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000, diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 44db6530cd..156c101f90 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/README.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 packages/llm/token-meter/README.md -README.md: a2deab11a31285ba598b8864d3a734ecf7c56620 -README.zh.md: e0c3a8aa123ca0acc374277cd92b415e0b0b3cb5 +README.md: 26bf9d4bc89b5ea1be72bb7d51aa6e6859bfd075 +README.zh.md: f018d22cee94d631bdfcdc26a3aa328e5d91152b diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index a2deab11a3..26bf9d4bc8 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -23,7 +23,7 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket ## Session projections -When the composition provides `ctx.sessionProjections`, token-meter registers three units through an optional child fiber. +Token-meter requires `ctx.sessionProjections` and registers three units. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. Usage chunks are counted even when a request later fails; a final assistant-message usage for the same `(turn, step)` replaces that sample instead of double-counting it. Reasoning remains an output subdivision. The single last-sample slot relies on a session-log ordering property: once a later step reports usage, a legal log never reports usage for an earlier step again. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index e0c3a8aa12..f018d22cee 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -23,7 +23,7 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成 ## 会话投影 -当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册三个单元。 +Token-meter 要求组合提供 `ctx.sessionProjections`,并注册三个单元。 `tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。 diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 2fa53f78f1..47df376a75 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -10,7 +10,6 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm' import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session' -// Type-only: resolves the optional projection registry Context declaration. import type {} from '@deepseek-ai/dsh-session-projection' import type { TokenMeasurement, @@ -24,6 +23,11 @@ import { estimateContent, estimateHeader, estimateMessage, ROLE_OVERHEAD } from import { foldSurfaceTokens } from './surface-fold.ts' export type * from './types.ts' +// Module-edge re-export: forces the emitted index.d.ts to import the +// projection-unit modules, so their SessionProjectionStateMap augmentations load +// in aggregate programs that only import the package root. +export type * from './usage-projection.ts' +export type * from './breakdown-projection.ts' interface MeasurementAnchor { readonly header: EpochHeader | undefined @@ -76,19 +80,17 @@ export class TokenMeter extends Service { // the public type excludes settings while validateConfigKeys rejects them. static Config: z = z.object({}) as unknown as z + static inject = ['sessionProjections'] + private readonly states = new WeakMap() constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') validateConfigKeys(config) - // Projection registration is an optional child: compositions without the - // generic registry keep the meter's standalone read shape. - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(tokenUsageProjectionDefinition) - projectionCtx.sessionProjections.register(contextPressureProjectionDefinition) - projectionCtx.sessionProjections.register(contextBreakdownProjectionDefinition) - }) + ctx.sessionProjections.register(tokenUsageProjectionDefinition) + ctx.sessionProjections.register(contextPressureProjectionDefinition) + ctx.sessionProjections.register(contextBreakdownProjectionDefinition) // 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/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 56ba67ee80..d72ba7f1f6 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -8,7 +8,6 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' import { CompactionId } from '@deepseek-ai/dsh-compaction' -import type {} from '../src/usage-projection.ts' const ZERO: TokenUsageProjection = { uncachedInputTokens: 0, diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index c12c5c248f..4d58cd9c94 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.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 packages/plan/plan-mode/README.md -README.md: 3eabe2cb3f04b434b7f908f7beca869f1022a59e -README.zh.md: f7d6a1f8e9f5ba95f8aad9457f3dde5fc415fdcf +README.md: 76575d603279c699112feeac82344a504af05a87 +README.zh.md: deada84a896edb0f6a0e597d25cead0f347bf543 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 3eabe2cb3f..76575d6032 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -6,7 +6,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc ## Durable state -`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`. +`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. The required `plan` projection unit folds committed mode, command settlement, and the mode at the latest request header, so resume, fork, and compaction recover the state directly from the session log. `ctx.planMode.set(agent, active)` appends the standalone `plan/mode` event immediately when the agent is idle, because no in-turn pre-step runs before the next prompt. While the agent is running, it holds a pending selection for the next accepted in-turn pre-step. It returns which happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state used to assemble the current step from a user's mid-turn selection. Initial and continuation pre-steps both apply pending selections; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). @@ -22,7 +22,7 @@ The Web client consumes the plugin-owned `/plan` command; other entry points may ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. A `command/run` record named `plan` with recorded `args` starts a candidate target (`off` → inactive, anything else → active); its paired `command/done` retains a successful selection and drops an error; `plan/mode` commits the logged state and clears the retained selection. Every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an unsettled or successful selection differs from the logged state. This remains a pure replay quantity, so host restarts, other tabs, and cold reads recover it from the log alone, and a rejected `/plan off` with images cannot leave a pending exit. The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. +This package requires `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)) and registers the `plan` projection unit. A `command/run` record named `plan` with recorded `args` starts a candidate target (`off` → inactive, anything else → active); its paired `command/done` retains a successful selection and drops an error; `plan/mode` commits the logged state and clears the retained selection; `request/header` records which mode assembled the latest request. Every other event returns the same state reference. Host logic reads the full state through `stateOf()`. The client `view` remains `{ active, pending }`, where `pending` is true only while an unsettled or successful selection differs from the logged state. The key merges into `SessionProjectionStateMap` for host state and `SessionProjectionMap` for the client view; carriers serve the latter on the history tail page and the `session/projection` push frame. ## Configuration diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index f7d6a1f8e9..deada84a89 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -6,7 +6,7 @@ ## 持久状态 -`plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。 +`plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。必需的 `plan` 投影单元折叠已提交模式、命令结算结果和最近一次请求头对应的模式,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复该状态。 `ctx.planMode.set(agent, active)` 会在 agent 空闲时立即追加独立的 `plan/mode` 事件,因为下一个提示词之前不会运行轮内 pre-step。agent 运行时,该方法会保留待生效选择,直到下一个被接受的轮内 pre-step。返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将用于组装当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 都会应用待生效选择;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个被接受的轮内 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 @@ -22,7 +22,7 @@ Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接 ## 会话投影 -当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择。其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该值仍完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它,被拒绝的带图 `/plan off` 也不会留下待退出状态。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。 +本包要求组合提供 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)),并注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择;`request/header` 记录最近一次请求由哪种模式组装。其他任何事件都返回同一个状态引用。host 逻辑通过 `stateOf()` 读取完整状态。客户端 `view` 仍为 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该 key 分别合并到 host 状态的 `SessionProjectionStateMap` 与客户端视图的 `SessionProjectionMap`;载体通过历史尾页和 `session/projection` 推送帧提供后者。 ## 配置 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index b7b94bf6cf..676baef697 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -6,8 +6,8 @@ * policy enforce restrictions independently and do not read or write plan * state. * - * The state in force is folded from the session log (`plan/mode`, last one - * wins), so resume and fork restore it without a live mirror. User selections + * The required `plan` projection folds the session log, so resume and fork + * restore the state without rescanning it in each consumer. User selections * remain pending until the next accepted in-turn pre-step. The service includes * the selected state in the proposed step assembly, then appends `plan/mode` * from `agent/pre-step` only when the step is accepted. Same-step request @@ -28,19 +28,14 @@ import { z as zod } from 'zod' import type { ZodType } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' +import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' -// Type-only edge: resolves `ctx.commands` for the optional command child. import type {} from '@deepseek-ai/dsh-commands' -// Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' -import type { PlanProjection } from './types.ts' -// The `plan` projection-key declaration lives in src/types.ts (its one home); -// this re-export projects the type face onto the package root AND keeps the -// module edge in the emitted index.d.ts, so aggregate programs consuming the -// declarations still receive the SessionProjectionMap merge. +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { PlanProjection, PlanUnitState } from './types.ts' export type * from './types.ts' declare module '@deepseek-ai/dsh-session/types' { @@ -48,7 +43,7 @@ declare module '@deepseek-ai/dsh-session/types' { /** * Whether plan mode is in force from this point on: log-only, non-surface, * whole-value replace. The last `plan/mode` wins; a log with none folds to - * inactive through {@link foldPlanMode}. + * inactive through the projection unit's fold. */ 'plan/mode': { active: boolean } } @@ -118,49 +113,11 @@ export function resolveConfig(config: PlanModeConfig): PlanModeConfig { return { section } } -/** - * Whether plan mode is active after the first `end` events. The last - * `plan/mode` wins; a prefix with none is inactive. - * - * @param events The session log or any prefix of it. - * @param end Fold `events[0, end)`; defaults to the whole log. - * @returns Whether plan mode is active. - */ -export function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean { - let active = false - let index = 0 - for (const event of events) { - if (index >= end) break - index++ - if (event.type === 'plan/mode') active = event.data.active - } - return active -} - -/** - * Projection unit state: the logged mode, the latest successful `/plan` - * selection not yet resolved by a `plan/mode` commit, and an execution whose - * paired `command/done` has not settled. Plain JSON (persisted-cache - * precondition). - */ -interface PlanUnitState { - active: boolean - /** The selection's target mode; null when no selection is outstanding. */ - wanted: boolean | null - /** The latest plan command awaiting its paired settlement. */ - running: { commandId: string; wanted: boolean } | null -} - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - plan: PlanUnitState - } -} - -const planUnitStateSchema: ZodType = zod.object({ +const planUnitStateSchema = zod.object({ active: zod.boolean(), wanted: zod.boolean().nullable(), running: zod.object({ commandId: zod.string(), wanted: zod.boolean() }).nullable(), + activeAtLastHeader: zod.boolean().nullable(), }) /** Wire payload schema of the `plan` projection. */ @@ -169,35 +126,48 @@ const planProjectionSchema: ZodType = zod.object({ pending: zod.boolean(), }) -/** Whether the log holds an opened turn without its closing `turn/end`. */ -function hasOpenTurn(events: readonly SessionEvent[]): boolean { - let open = false - for (const event of events) { - if (event.type === 'turn/start') open = true - else if (event.type === 'turn/end') open = false - } - return open -} - -/** Plan state at the last logged request header, or `undefined` before the first header. */ -function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined { - let lastHeader = -1 - let index = 0 - for (const event of events) { - if (event.type === 'request/header') lastHeader = index - index++ - } - if (lastHeader < 0) return undefined - return foldPlanMode(events, lastHeader + 1) -} +/** Projection of logged plan selections and committed mode. */ +export const planProjectionDefinition = { + key: 'plan', + stateVersion: 3, + stateSchema: planUnitStateSchema, + init: () => ({ active: false, wanted: null, running: null, activeAtLastHeader: null }), + apply: (state, event) => { + if (event.type === 'command/run' && event.data.name === 'plan') { + if (event.data.args === undefined) return state + const wanted = event.data.args.trim() !== 'off' + return { ...state, running: { commandId: event.data.commandId, wanted } } + } + if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) { + const wanted = event.data.kind === 'success' && state.running.wanted !== state.active + ? state.running.wanted + : null + return { ...state, wanted, running: null } + } + if (event.type === 'plan/mode') { + return { ...state, active: event.data.active, wanted: null } + } + if (event.type === 'request/header') { + return { ...state, activeAtLastHeader: state.active } + } + return state + }, + wire: { + viewSchema: planProjectionSchema, + view: (state) => { + const wanted = state.running?.wanted ?? state.wanted + return { active: state.active, pending: wanted !== null && wanted !== state.active } + }, + }, +} satisfies ProjectionDefinition<'plan', PlanUnitState> /** * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, * the `plan:policy` section, the `/plan` command, and the stable exit tool. - * UIs observe committed flips through `session/event`; there is no live mirror. + * Client carriers expose the projection's cropped `{ active, pending }` view. */ export class PlanModeController extends Service { - static inject = ['tools', 'systemPrompt'] + static inject = ['tools', 'systemPrompt', 'sessionProjections'] /** Validated deployment-owned guidance. */ private readonly section: string @@ -243,50 +213,11 @@ export class PlanModeController extends Service { text: (context) => { if (context.agent === undefined) return '' const pending = this.pendingIntents.get(context.agent.session) - return (pending?.active ?? foldPlanMode(context.agent.session.events)) ? this.section : '' + return (pending?.active ?? this.loggedActive(context.agent.session)) ? this.section : '' }, }) - // The plan projection unit (session-projection RFC): a pure event fold - // serving clients the whole {active, pending} value. `command/run` - // records the user's logged /plan selection, its paired `command/done` - // keeps only successful selections, and `plan/mode` records that - // selection and clears it. Pending is thereby a pure - // replay quantity: host restarts, other tabs, and cold reads all recover - // it from the log alone. The unit child activates only when a projection - // registry is composed (headless assemblies stay unaffected). - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'plan', PlanUnitState>({ - key: 'plan', - stateSchema: planUnitStateSchema, - init: () => ({ active: false, wanted: null, running: null }), - apply: (state, event) => { - if (event.type === 'command/run' && event.data.name === 'plan') { - if (event.data.args === undefined) return state - const wanted = event.data.args.trim() !== 'off' - return { ...state, running: { commandId: event.data.commandId, wanted } } - } - if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) { - const wanted = event.data.kind === 'success' && state.running.wanted !== state.active - ? state.running.wanted - : null - return { ...state, wanted, running: null } - } - if (event.type === 'plan/mode') { - return { ...state, active: event.data.active, wanted: null } - } - return state - }, - wire: { - viewSchema: planProjectionSchema, - view: (state) => { - const wanted = state.running?.wanted ?? state.wanted - return { active: state.active, pending: wanted !== null && wanted !== state.active } - }, - }, - stateVersion: 2, - }) - }) + ctx.sessionProjections.register(planProjectionDefinition) // The command child activates only when a command registry is composed. ctx.inject(['commands'], (commandCtx) => { @@ -311,7 +242,7 @@ export class PlanModeController extends Service { // Repeat the queued wording while an exit still awaits the // next accepted pre-step; only a truly inactive session reads // idempotent. - return foldPlanMode(agent.session.events) + return this.loggedActive(agent.session) ? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' } : { kind: 'success', text: 'Plan mode is already inactive.' } } @@ -355,7 +286,7 @@ export class PlanModeController extends Service { execute: async (args, exec) => { const agent = exec.agent if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`) - if (!foldPlanMode(agent.session.events)) { + if (!this.loggedActive(agent.session)) { throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`) } if (!/^#\s+\S/.test(args.plan.trim())) { @@ -427,6 +358,19 @@ export class PlanModeController extends Service { })) } + private loggedActive(session: Session): boolean { + /* v8 ignore next -- plan-mode registers its own plan unit, so the key is always present */ + return this.ctx.sessionProjections.stateOf(session, 'plan')?.active ?? false + } + + private hasOpenTurn(session: Session): boolean { + return (this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.openTurn ?? null) !== null + } + + private loggedActiveAtLastHeader(session: Session): boolean | undefined { + return this.ctx.sessionProjections.stateOf(session, 'plan')?.activeAtLastHeader ?? undefined + } + /** * Read the logged plan state and any selected state awaiting the next * accepted in-turn pre-step. @@ -435,7 +379,7 @@ export class PlanModeController extends Service { * @returns Current logged state plus a pending selection, when present. */ get(agent: Agent): { active: boolean; pending?: boolean } { - const active = foldPlanMode(agent.session.events) + const active = this.loggedActive(agent.session) const pending = this.pendingIntents.get(agent.session) return pending === undefined ? { active } : { active, pending: pending.active } } @@ -459,15 +403,15 @@ export class PlanModeController extends Service { set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' { const session = agent.session const pending = this.pendingIntents.get(session) - const target = pending?.active ?? foldPlanMode(session.events) + const target = pending?.active ?? this.loggedActive(session) if (active === target) return 'noop' - if (hasOpenTurn(session.events)) { + if (this.hasOpenTurn(session)) { this.pendingIntents.set(session, { active, narrate: true }) - return foldPlanMode(session.events) === active ? 'cancelled' : 'queued' + return this.loggedActive(session) === active ? 'cancelled' : 'queued' } // No open turn: commit now. Delete only after append succeeds so a // failed durable write leaves the selection retryable, not dropped. - if (active === foldPlanMode(session.events)) { + if (active === this.loggedActive(session)) { this.pendingIntents.delete(session) return 'cancelled' } @@ -483,7 +427,7 @@ export class PlanModeController extends Service { const pending = this.pendingIntents.get(session) if (pending === undefined) return const target = pending.active - if (target === foldPlanMode(session.events)) { + if (target === this.loggedActive(session)) { this.pendingIntents.delete(session) return } @@ -495,7 +439,7 @@ export class PlanModeController extends Service { /** Build a user-switch notice when the last logged header described the other mode. */ private narration(session: Session, target: boolean): UserMessage | undefined { - const told = planModeAtLastHeader(session.events) + const told = this.loggedActiveAtLastHeader(session) if (told === undefined || told === target) return const text = target ? 'The user switched this session to plan mode.' diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts index 8efd32f22a..ffb19aee1e 100644 --- a/packages/plan/plan-mode/src/types.ts +++ b/packages/plan/plan-mode/src/types.ts @@ -1,9 +1,9 @@ /** * Pure types of the plan domain: the ONE home of the `plan` projection-key - * declaration, free of this package's host-side value imports (cordis - * service, dsh-tools, dsh-agent). Two namespace projections serve it — - * `./types` for host consumers, `./client` for client aggregates — with zero - * content duplication. + * declaration, free of this package's host-side value imports (cordis, + * dsh-tools, dsh-agent). Two namespace projections serve it — `./types` for + * host consumers and `./client` for client aggregates — with zero content + * duplication. * * @module @deepseek-ai/dsh-plan-mode/types */ @@ -21,7 +21,23 @@ export interface PlanProjection { pending: boolean } +/** Host state used to derive {@link PlanProjection}. */ +export interface PlanUnitState { + /** Logged plan mode. */ + active: boolean + /** The selection's target mode; null when no selection is outstanding. */ + wanted: boolean | null + /** The latest plan command awaiting its paired settlement. */ + running: { commandId: string; wanted: boolean } | null + /** Active state recorded by the latest `request/header`, or null. */ + activeAtLastHeader: boolean | null +} + declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Host plan-mode fold state. */ + plan: PlanUnitState + } interface SessionProjectionMap { /** Plan collaboration state folded from the plan command lifecycle and `plan/mode` events. */ plan: PlanProjection diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 77efc3beb3..aa43e9ed1c 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -6,7 +6,9 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import PlanModeController, { foldPlanMode } from '@deepseek-ai/dsh-plan-mode' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import PlanModeController, { planProjectionDefinition } from '@deepseek-ai/dsh-plan-mode' +import type { PlanUnitState } from '@deepseek-ai/dsh-plan-mode/types' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' const PLAN_CONFIG = { section: 'Test plan mode instructions.' } @@ -23,6 +25,7 @@ async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -51,6 +54,13 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } +/** The logged-mode fold the plan projection unit serves: last `plan/mode` wins. */ +function foldPlanMode(events: readonly SessionEvent[]): boolean { + let state: PlanUnitState = planProjectionDefinition.init() + for (const event of events) state = planProjectionDefinition.apply(state, event) + return state.active +} + function findEvent( log: readonly SessionEvent[], type: T, diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 8285147953..3a1602430b 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import UserQuestionService, { @@ -11,8 +11,11 @@ import UserQuestionService, { } from '@deepseek-ai/dsh-user-questions' import CommandRuntime from '@deepseek-ai/dsh-commands' import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime' -import PlanModeController, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' +import PlanModeController, { EXIT_PLAN_MODE, planProjectionDefinition, resolveConfig } from '../src/index.ts' import type { PlanModeConfig } from '../src/index.ts' +import type { PlanUnitState } from '../src/types.ts' const TEST_PLAN_SECTION = 'Test plan mode instructions.' const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig @@ -65,8 +68,25 @@ function assembleFor(ctx: Context, agent: Agent) { return ctx.systemPrompt.assemble({ agent, scope: agent }) } +function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean { + let state: PlanUnitState = planProjectionDefinition.init() + let index = 0 + for (const event of events) { + if (index >= end) break + index++ + state = planProjectionDefinition.apply(state, event) + } + return state.active +} + +async function mountProjectionSeam(ctx: Context): Promise { + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +} + async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise { const ctx = new Context() + await mountProjectionSeam(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(PlanModeController, config) @@ -282,6 +302,7 @@ describe('the boundary flush', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) const agent = await agentWithSession(ctx) openTurn(agent.session) @@ -434,6 +455,7 @@ describe('the soft layer', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await mountProjectionSeam(ctx) ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { const final = await next() final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }] @@ -461,6 +483,7 @@ describe('the soft layer', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime, { mode: 'code' }) await ctx.plugin(FakeRuntime) + await mountProjectionSeam(ctx) await ctx.plugin(PlanModeController, PLAN_CONFIG) registerNamedTools(ctx, ['read', 'write']) const agent = await agentWithSession(ctx, 'agent-1', { active: true }) @@ -482,6 +505,7 @@ describe('the soft layer', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime, { mode: 'both' }) await ctx.plugin(FakeRuntime) + await mountProjectionSeam(ctx) await ctx.plugin(PlanModeController, PLAN_CONFIG) registerNamedTools(ctx, ['read', 'write']) const agent = await agentWithSession(ctx, 'agent-1', { active: true }) @@ -503,6 +527,7 @@ describe('the soft layer', () => { await withPlanMode.plugin(SystemPrompt) await withPlanMode.plugin(ToolRuntime, { mode: 'code' }) await withPlanMode.plugin(FakeRuntime) + await mountProjectionSeam(withPlanMode) await withPlanMode.plugin(PlanModeController, PLAN_CONFIG) registerNamedTools(withPlanMode, ['read', 'write']) const agent = await agentWithSession(withPlanMode) @@ -716,6 +741,7 @@ describe('/plan', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(CommandRuntime) + await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) await new Promise(resolve => setImmediate(resolve)) const agent = await agentWithSession(ctx) @@ -862,6 +888,7 @@ describe('exit_plan_mode', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime, { mode: 'code' }) await ctx.plugin(ExitRuntime) + await mountProjectionSeam(ctx) await ctx.plugin(PlanModeController, PLAN_CONFIG) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) @@ -1037,6 +1064,7 @@ describe('exit_plan_mode', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) @@ -1101,6 +1129,7 @@ describe('HMR disposal', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) const agent = await agentWithSession(ctx, 'disposed-recovery') openTurn(agent.session) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index e0ad20148c..e4f92cef03 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -1,14 +1,4 @@ -/** - * The `plan` projection unit (session-projection RFC's complete example): a - * event fold over the session log. `command/run` records named `plan` with - * recorded input set the candidate target (`off` → false, anything else → - * true); `command/done` keeps successful candidates and drops failures; - * `plan/mode` commits and clears a selection. `view` reports pending only while - * an outstanding selection differs from the logged state. - * Pending is thereby a pure replay quantity — a cold fold answers it without - * the service's in-memory intent. Composition without plan-mode has no `plan` - * key; unloading the fiber removes it (HMR safety). - */ +/** Plan projection behavior. */ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -48,20 +38,13 @@ async function harness(withPlanMode: boolean): Promise { } /** Append one logged /plan selection record (the executor's command/run shape). */ -function runPlanCommand(session: Session, args: string, index: number): CommandId { - const commandId = CommandId(`plan-proj-${String(index)}`) +function runPlanCommand(session: Session, args: string, index: number): void { session.append('command/run', { - commandId, + commandId: CommandId(`plan-proj-${String(index)}`), name: 'plan', args, source: { kind: 'user' }, }) - return commandId -} - -/** Append the paired settlement for one projected plan command. */ -function settlePlanCommand(session: Session, commandId: CommandId, kind: 'success' | 'error'): void { - session.append('command/done', { commandId, kind }) } /** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */ @@ -79,23 +62,15 @@ describe('plan projection unit', () => { it('a logged /plan selection reads pending until plan/mode records it', async () => { const bench = await harness(true) - const commandId = runPlanCommand(bench.session, '', 0) + runPlanCommand(bench.session, '', 0) expect(bench.values().plan).toEqual({ active: false, pending: true }) - settlePlanCommand(bench.session, commandId, 'success') + // A repeated identical selection returns the same state reference (no frame). + runPlanCommand(bench.session, '', 1) expect(bench.values().plan).toEqual({ active: false, pending: true }) commitPlanMode(bench.session, true, 0) expect(bench.values().plan).toEqual({ active: true, pending: false }) }) - it('drops a plan selection when its command settles with an error', async () => { - const bench = await harness(true) - commitPlanMode(bench.session, true, 0) - const commandId = runPlanCommand(bench.session, 'off', 0) - expect(bench.values().plan).toEqual({ active: true, pending: true }) - settlePlanCommand(bench.session, commandId, 'error') - expect(bench.values().plan).toEqual({ active: true, pending: false }) - }) - it('folds `off` args and non-plan commands correctly, and a matching selection is not pending', async () => { const bench = await harness(true) commitPlanMode(bench.session, true, 0) @@ -124,6 +99,28 @@ describe('plan projection unit', () => { expect(bench.values().plan).toEqual({ active: false, pending: true }) }) + it('freezes the active state across a request/header into activeAtLastHeader', async () => { + const bench = await harness(true) + commitPlanMode(bench.session, true, 0) + expect(bench.values().plan).toEqual({ active: true, pending: false }) + expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBeNull() + bench.session.append('request/header', { + header: { config: { provider: 'test', model: 'test-model' } }, + reason: 'initial', + }) + expect(bench.values().plan).toEqual({ active: true, pending: false }) + expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true) + commitPlanMode(bench.session, false, 1) + expect(bench.values().plan).toEqual({ active: false, pending: false }) + expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true) + bench.session.append('request/header', { + header: { config: { provider: 'test', model: 'test-model' } }, + reason: 'change', + }) + expect(bench.values().plan).toEqual({ active: false, pending: false }) + expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(false) + }) + it('has no plan key when plan-mode is not composed', async () => { const bench = await harness(false) expect('plan' in bench.values()).toBe(false) @@ -144,7 +141,7 @@ describe('plan projection unit', () => { // memory involved, the fold alone answers {active:false, pending:true}. const cold = await harness(true) for (const event of bench.session.events) { - if (event.type === 'command/run' || event.type === 'command/done' || event.type === 'plan/mode') { + if (event.type === 'command/run' || event.type === 'plan/mode') { cold.session.append(event.type, event.data) } } diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index a8f374d765..78c45d8d8e 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-settings-file": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index dda3644f55..6d26250fe1 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -7,6 +7,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import InvariantRegistry from '@deepseek-ai/dsh-invariants' @@ -30,6 +31,7 @@ async function harness(roster: Partial = {}): Promise { await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster }) await ctx.plugin(InvariantRegistry) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 824308e009..f5016d33df 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -9,6 +9,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -48,6 +49,7 @@ async function harness(roster: Config = { default: 'standard', roots: ROOTS, inc await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentPresets, roster) return ctx @@ -417,6 +419,7 @@ describe('the preset file is an input, never a persistence target', () => { await scoped.plugin(SystemPrompt, { persona: '' }) await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) + await scoped.plugin(SessionProjectionRegistry) await scoped.plugin(AgentLoop, { agents: [] }) await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) @@ -582,6 +585,7 @@ describe('replacing a composition', () => { await scoped.plugin(SystemPrompt, { persona: '' }) await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) + await scoped.plugin(SessionProjectionRegistry) await scoped.plugin(AgentLoop, { agents: [] }) await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) const handle = await scoped.agents.create({ diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index e3af9e1ec1..436b344e38 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -15,6 +15,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' @@ -46,6 +47,7 @@ async function harness( await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) const settingsFiber = ctx.plugin(FileSettingsProvider, { path: settingsFile, watch: false }) await settingsFiber diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml index f12249398a..cce8fe1531 100644 --- a/packages/sandbox/sandbox-policy/README.i18n.yaml +++ b/packages/sandbox/sandbox-policy/README.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 packages/sandbox/sandbox-policy/README.md -README.md: c85420019b490d311360f310c04212ac6cd737d1 -README.zh.md: 58354adf55a55951cad1ea9adb88aea2457c7f7b +README.md: 2e83fb5ffebb024cb6d9dc94708520f4130d10e9 +README.zh.md: cc0e24febadaa2f0232f9fb10158790ff5a9af19 diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md index c85420019b..2e83fb5ffe 100644 --- a/packages/sandbox/sandbox-policy/README.md +++ b/packages/sandbox/sandbox-policy/README.md @@ -18,7 +18,7 @@ Filesystem tools, one-shot bash commands, and terminal sessions may enforce the - `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution. - `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`. - `sandbox:policy` — a request-time cache-safe context contribution derived directly from `resolve({ session })`. It states the mode's capability-neutral file-effect contract and the canonical session workspace under `workspace-write`; tool owners retain operation-specific denial and escalation guidance. -- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`. +- `overrideOf(session)` — the last `sandbox/mode` value from the required `sandboxMode` projection, or `undefined`, used inside `resolve()`. - `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. @@ -26,7 +26,7 @@ The optional `./invariant` companion rejects a forged durable `sandbox/mode` eve ## The per-session store -A runtime switch 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 stays log-only; before the next request, the owner contributes the current fact to the full runtime-context snapshot. +A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? projection state ?? 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 stays log-only; before the next request, the owner contributes the current fact to the full runtime-context snapshot. ## Model Experience diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md index 58354adf55..cc0e24feba 100644 --- a/packages/sandbox/sandbox-policy/README.zh.md +++ b/packages/sandbox/sandbox-policy/README.zh.md @@ -18,7 +18,7 @@ - `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。 - `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根目录。 - `sandbox:policy`:直接派生自 `resolve({ session })` 的请求时缓存安全上下文贡献。它说明该模式中与具体能力无关的文件操作约定,以及 `workspace-write` 下规范化的会话工作区;工具归属方仍负责特定于操作的拒绝与升权引导。 -- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。 +- `overrideOf(session)`:从必需的 `sandboxMode` 投影读取最后一项 `sandbox/mode` 值,没有则为 `undefined`;在 `resolve()` 内使用。 - `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。 - `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。 @@ -26,7 +26,7 @@ ## 逐会话存储 -运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? fold(events) ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。工作区标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件仍只进入日志;在下一次请求前,归属方会将当前事实贡献给完整运行时上下文快照。 +运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? projection state ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。工作区标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件仍只进入日志;在下一次请求前,归属方会将当前事实贡献给完整运行时上下文快照。 ## 模型体验 diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 3038e99718..21488a8275 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -37,10 +37,12 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -48,6 +50,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index eee5a43669..5f3fa72382 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -20,14 +20,15 @@ import { resolve as resolvePath } from 'node:path' import { Context, Service } from '@deepseek-ai/cordis' +import { z as zod } from 'zod' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' -import { effectiveSandboxMode } from './session-mode.ts' -export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts' +export { SANDBOX_MODES, setSandboxMode } from './session-mode.ts' /** Resolve filesystem identity before lexical normalization can erase symlink-sensitive components. */ function resolveWorkspaceRoot(path: string): string { @@ -88,6 +89,22 @@ export interface SandboxPolicyRequest { * section. Tool layers call {@link resolve} for each execution so a session's * mode log and immutable cwd travel together to every enforcing capability. */ +/** The sandbox-mode projection's state schema (state equals the public shape). */ +const sandboxModeStateSchema = zod.union([ + zod.literal('read-only'), + zod.literal('workspace-write'), + zod.literal('danger-full-access'), +]).nullable() + +type SandboxModeState = zod.infer +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Last logged sandbox-mode override, or null before one (deployment default applies at resolve time). */ + sandboxMode: SandboxModeState + } +} + +/** The sandbox policy seam: the deployment default mode and workspace-write root, with per-session overrides folded from the log. */ export class SandboxPolicyService extends Service { // Inline schema call: the config catalog walks `static Config` statically. static Config: z = z.object({ @@ -97,6 +114,8 @@ export class SandboxPolicyService extends Service { workspaceRoot: z.string(), }) + static inject = ['sessionProjections'] + /** The deployment default mode — the fallback beneath a session override. */ readonly defaultMode: SandboxMode /** The absolute `workspace-write` fallback root for calls without a session cwd. */ @@ -109,6 +128,14 @@ export class SandboxPolicyService extends Service { this.defaultMode = config.mode as SandboxMode this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd()) + ctx.sessionProjections.register({ + key: 'sandboxMode', + stateVersion: 1, + stateSchema: sandboxModeStateSchema, + init: () => null, + apply: (state, event) => (event.type === 'sandbox/mode' ? event.data.mode : state), + }) + ctx.inject(['systemPrompt'], (scope: Context) => { scope.systemPrompt.context({ name: 'sandbox:policy', @@ -147,7 +174,7 @@ export class SandboxPolicyService extends Service { * @returns the last logged mode, or `undefined` without one. */ overrideOf(session: Session): SandboxMode | undefined { - return effectiveSandboxMode(session.events) + return this.ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? undefined } } diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts index 165a462046..0bc7110efc 100644 --- a/packages/sandbox/sandbox-policy/src/session-mode.ts +++ b/packages/sandbox/sandbox-policy/src/session-mode.ts @@ -2,7 +2,7 @@ * Per-session sandbox-mode override: the session log as the store. A runtime * switch (a UI policy control or test scenario) is recorded as one * `sandbox/mode` event on the session it applies to; - * `effective = fold(events) ?? the deployment default`, so an override + * `effective = projection state ?? the deployment default`, so an override * survives restart by replay, two sessions can never see each other's state, * and there is no external config store. The event is log-only (the * `approval/*` precedent): the policy owner projects the fold into each model @@ -18,7 +18,7 @@ * @module dsh-sandbox-policy/session-mode */ -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' declare module '@deepseek-ai/dsh-session/types' { @@ -27,7 +27,7 @@ declare module '@deepseek-ai/dsh-session/types' { * The session's sandbox mode was switched — log-only (like `approval/*`; * NOT a surface event, carries no `surfaceOp`): durable and replayable, * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks + * override (folded by the sandboxMode projection unit). `source: 'delegation'` marks * an override seeded into a child; an absent source is a runtime switch. */ 'sandbox/mode': { @@ -41,27 +41,11 @@ declare module '@deepseek-ai/dsh-session/types' { /** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */ export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access'] -/** - * The session's sandbox-mode override: the last `sandbox/mode` event in the - * log, or undefined when the session never switched (callers apply the - * deployment default). The pure fold — resume needs no catch-up machinery - * because replaying the log IS the state. - * @param events - session events in log order (other event types are skipped). - * @returns the mode of the last switch event, or undefined without one. - */ -export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] as SessionEvent - if (event.type === 'sandbox/mode') return event.data.mode - } - return undefined -} - /** * THE write path for a session's sandbox-mode override: appends exactly one * `sandbox/mode` event — the switch IS its event; nothing mutates mode state * out of band. Takes effect on the session's next confined call (bash or fs) - * — the consumers fold on every read. + * — consumers read the shared projection state. * @param session - the session the override belongs to. * @param mode - the mode every subsequent confined call in this session runs * under (until the next switch). diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 0a239114aa..86c9fa3905 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -11,11 +11,13 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import SandboxPolicyService, { SANDBOX_MODES, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt, { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt' async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, config) return ctx } @@ -125,6 +127,9 @@ describe('SandboxPolicyService', () => { it('rejects a mode outside the closed vocabulary at load', async () => { const ctx = new Context() + // Config validation runs when the fiber activates, and the policy seam + // requires the projection registry (mandatory injection) to activate. + await ctx.plugin(SessionProjectionRegistry) // schemastery rejects the union violation when the plugin loads. await expect(ctx.plugin(SandboxPolicyService, { mode: 'yolo' as never })).rejects.toThrow() }) @@ -132,6 +137,7 @@ describe('SandboxPolicyService', () => { it('disposes the service and context contribution from a child fiber (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(SandboxPolicyService, {}) expect(ctx.sandboxPolicy).toBeDefined() expect(await policyContext(ctx, session('sess-hmr'))).toContain('read-only') @@ -145,6 +151,7 @@ describe('sandbox:policy request context', () => { async function promptMounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, config) return ctx } @@ -212,12 +219,13 @@ describe('the sandbox/mode session kit', () => { expect(SANDBOX_MODES).toEqual(['read-only', 'workspace-write', 'danger-full-access']) }) - it('effectiveSandboxMode folds to the last switch, or undefined without one', () => { + it('the sandboxMode projection folds to the last switch, or null without one', async () => { + const ctx = await mounted() const session = Session.create(SessionId('sess-fold')) - expect(effectiveSandboxMode(session.events)).toBeUndefined() + expect(ctx.sessionProjections.snapshot(session).values.sandboxMode).toBeNull() setSandboxMode(session, 'workspace-write') setSandboxMode(session, 'read-only') - expect(effectiveSandboxMode(session.events)).toBe('read-only') + expect(ctx.sessionProjections.snapshot(session).values.sandboxMode).toBe('read-only') }) it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { diff --git a/packages/sandbox/sandbox-policy/tsconfig.json b/packages/sandbox/sandbox-policy/tsconfig.json index e909a20be1..3a08a053b6 100644 --- a/packages/sandbox/sandbox-policy/tsconfig.json +++ b/packages/sandbox/sandbox-policy/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/schedule/schedule/tests/jsonl-restart.spec.ts b/packages/schedule/schedule/tests/jsonl-restart.spec.ts index 23371feee6..d18e35aac9 100644 --- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts +++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -51,6 +52,7 @@ async function mountRuntime(root: string, adapter: RecordingAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(PersistenceProbe) ctx.on('session/flush', () => {}) await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index ad55c29dd8..2216ee6b4c 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 495f1d90f4..c5f0019aa5 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -61,6 +62,7 @@ async function mockCompletionServer(): Promise<{ url: string; requests: unknown[ async function makeHarness(storageDir: string) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(agentCore, { workspaceContext: false }) await ctx.plugin(SubagentRuntime) await ctx.plugin(JsonlSessionPersistence, { root: storageDir }) diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 4bab825e49..9926aead94 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 1c4569df4c..4c528e9601 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -194,7 +194,7 @@ interface CursorPayload { /** Concrete SQLite owner of the combined `ctx.sessionQuery` service. */ export class SqliteSessionQueryEngine extends SessionQueryEngine { - static override inject = ['sessions'] + static override inject = ['sessions', 'sessionProjections'] static Config: z = z.object({ path: z.string().required(), diff --git a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts index b68ac7072a..f38a4d53bb 100644 --- a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts +++ b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts @@ -8,8 +8,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite' import SqliteSessionQueryEngine, * as queryModule from '@deepseek-ai/dsh-session-query-sqlite' import { mkdtemp, rm } from 'node:fs/promises' @@ -35,6 +35,7 @@ describe('dsh-session-query-sqlite real Loader path', () => { const persistencePath = await temporaryPath('canonical.db') const searchPath = await temporaryPath('derived.db') const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionStore) const persistence = await ctx.plugin(SqliteSessionPersistence, { path: persistencePath }) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 0a565fe021..93f5edf30a 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -6,6 +6,7 @@ import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' @@ -182,6 +183,7 @@ class TestPersistence extends SessionPersistence { async function liveContext(config: ConstructorParameters[1] = { path: ':memory:' }): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SqliteSessionQueryEngine, config) return ctx } @@ -221,6 +223,7 @@ describe('SQLite session search', () => { const path = await temporaryPath('unopened.db') const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const search = await ctx.plugin(SqliteSessionQueryEngine, { path, openAt: 'first-search', @@ -235,6 +238,7 @@ describe('SQLite session search', () => { const path = await temporaryPath('never-mode.db') const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const search = await ctx.plugin(SqliteSessionQueryEngine, { path, openAt: 'never' }) const service = ctx.sessionQuery as SqliteSessionQueryEngine expect(service.config.openAt).toBe('never') @@ -264,6 +268,7 @@ describe('SQLite session search', () => { it('opens once on the first search and reuses readiness for later searches', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'first-search', @@ -281,6 +286,7 @@ describe('SQLite session search', () => { it('shares one readiness promise across concurrent first searches', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'first-search', @@ -1097,6 +1103,7 @@ describe('SQLite reconciliation and source lifecycle', () => { ]) const first = new Context() await first.plugin(SessionStore) + await first.plugin(SessionProjectionRegistry) const firstPersistence = await first.plugin(TestPersistence) const firstSearch = await first.plugin(SqliteSessionQueryEngine, { path }) await first.sessionQuery.searchSessions({ query: 'needle' }) @@ -1117,6 +1124,7 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.set({ meta: added, events: messageEvents('added needle') }) const second = new Context() await second.plugin(SessionStore) + await second.plugin(SessionProjectionRegistry) const secondPersistence = await second.plugin(TestPersistence) const secondSearch = await second.plugin(SqliteSessionQueryEngine, { path }) const result = await second.sessionQuery.searchSessions({ query: 'needle' }) @@ -1146,6 +1154,7 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) const first = new Context() await first.plugin(SessionStore) + await first.plugin(SessionProjectionRegistry) const persistence = await first.plugin(TestPersistence) const live = first.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: 10 } }) const search = await first.plugin(SqliteSessionQueryEngine, { path }) @@ -1155,6 +1164,7 @@ describe('SQLite reconciliation and source lifecycle', () => { const second = new Context() await second.plugin(SessionStore) + await second.plugin(SessionProjectionRegistry) const persistenceAgain = await second.plugin(TestPersistence) const searchAgain = await second.plugin(SqliteSessionQueryEngine, { path }) await expect(second.sessionQuery.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] }) @@ -1263,6 +1273,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const path = `${await temporaryPath()}\0` const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await expect(ctx.plugin(SqliteSessionQueryEngine, { path })).rejects.toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', @@ -1297,6 +1308,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => augmented.close() const augmentedCtx = new Context() await augmentedCtx.plugin(SessionStore) + await augmentedCtx.plugin(SessionProjectionRegistry) await expect(augmentedCtx.plugin(SqliteSessionQueryEngine, { path: augmentedPath })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) expect(augmentedCtx.sessionQuery).toBeUndefined() @@ -1314,6 +1326,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => currentAugmented.close() const currentAugmentedCtx = new Context() await currentAugmentedCtx.plugin(SessionStore) + await currentAugmentedCtx.plugin(SessionProjectionRegistry) await expect(currentAugmentedCtx.plugin(SqliteSessionQueryEngine, { path: currentAugmentedPath, journalMode: 'delete', @@ -1334,6 +1347,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => foreign.close() const foreignCtx = new Context() await foreignCtx.plugin(SessionStore) + await foreignCtx.plugin(SessionProjectionRegistry) await expect(foreignCtx.plugin(SqliteSessionQueryEngine, { path: foreignPath, journalMode: 'delete' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) expect(foreignCtx.sessionQuery).toBeUndefined() @@ -1350,6 +1364,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => wildcard.close() const wildcardCtx = new Context() await wildcardCtx.plugin(SessionStore) + await wildcardCtx.plugin(SessionProjectionRegistry) await expect(wildcardCtx.plugin(SqliteSessionQueryEngine, { path: wildcardPath, journalMode: 'delete', @@ -1368,6 +1383,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => otherApp.close() const otherAppCtx = new Context() await otherAppCtx.plugin(SessionStore) + await otherAppCtx.plugin(SessionProjectionRegistry) await expect(otherAppCtx.plugin(SqliteSessionQueryEngine, { path: otherAppPath })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) expect(otherAppCtx.sessionQuery).toBeUndefined() @@ -1384,6 +1400,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => try { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await expect(ctx.plugin(SqliteSessionQueryEngine, { path })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) await new Promise((resolve) => { setImmediate(resolve) }) @@ -1402,6 +1419,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const lazyCtx = new Context() await lazyCtx.plugin(SessionStore) + await lazyCtx.plugin(SessionProjectionRegistry) const lazy = await lazyCtx.plugin(SqliteSessionQueryEngine, { path, openAt: 'first-search', @@ -1413,6 +1431,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const eagerCtx = new Context() await eagerCtx.plugin(SessionStore) + await eagerCtx.plugin(SessionProjectionRegistry) await expect(eagerCtx.plugin(SqliteSessionQueryEngine, { path })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) expect(eagerCtx.sessionQuery).toBeUndefined() @@ -1732,6 +1751,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => TestPersistence.reset() const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const search = await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:' }) const persistence = await ctx.plugin(TestPersistence) const optional = (ctx.sessionQuery as unknown as { @@ -1755,6 +1775,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const searchPath = await temporaryPath('derived.db') const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const persistence = await ctx.plugin(SqliteSessionPersistence, { path: persistencePath }) const search = await ctx.plugin(SqliteSessionQueryEngine, { path: searchPath }) const meta = header('real', 10, { cwd: '/work' }) @@ -1780,6 +1801,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const first = new Context() await first.plugin(SessionStore) + await first.plugin(SessionProjectionRegistry) const persistenceA = await first.plugin(SqliteSessionPersistence, { path: persistencePathA }) await first.sessionPersistence.create(shared) await first.sessionPersistence.append(shared.id, messageEvents('alpha source')) @@ -1793,6 +1815,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const reopened = new Context() await reopened.plugin(SessionStore) + await reopened.plugin(SessionProjectionRegistry) const persistenceAAgain = await reopened.plugin(SqliteSessionPersistence, { path: persistencePathA }) const reopenedInspect = vi.spyOn(reopened.sessionPersistence, 'inspect') const searchAAgain = await reopened.plugin(SqliteSessionQueryEngine, { path: searchPath }) @@ -1804,6 +1827,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const second = new Context() await second.plugin(SessionStore) + await second.plugin(SessionProjectionRegistry) const persistenceB = await second.plugin(SqliteSessionPersistence, { path: persistencePathB }) await second.sessionPersistence.create(shared) await second.sessionPersistence.append(shared.id, messageEvents('bravo source')) diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index eb82890646..4eb75cda1e 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -5,6 +5,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId, } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { buildSessionEventRecords, @@ -274,6 +275,7 @@ describe('session-query document and filter helpers', () => { it('exposes the scan path on the combined query service', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQueryEngine) const session = ctx.sessions.create(id) session.append('user/message', createUserMessage({ @@ -290,6 +292,7 @@ describe('session-query document and filter helpers', () => { it('registers exact and abstract search behavior under one ctx key', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(TestSessionQueryEngine) const session = ctx.sessions.create(id) await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) 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 119188eb5a..83f5e1d040 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -2,6 +2,7 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import SessionQueryEngine, { @@ -9,9 +10,11 @@ import SessionQueryEngine, { type SessionEventSurface, type SessionQueryErrorCode, } from '@deepseek-ai/dsh-session-query' -import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title' +import { SessionTitleProviderId, SessionTitleService } from '@deepseek-ai/dsh-session-title' import { TestSessionQueryEngine } from './test-service.ts' +const TITLE_SERVICE_CONFIG = { fallbackMaxWords: 8, fallbackMaxBytes: 64, maxTitleBytes: 256 } + function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } } @@ -125,6 +128,7 @@ class TestPersistence extends SessionPersistence { async function liveContext(config: ConstructorParameters[1] = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQueryEngine, config) return ctx } @@ -487,6 +491,7 @@ describe('session-query exact reads', () => { }, ]) const ctx = await liveContext() + await ctx.plugin(SessionTitleService, TITLE_SERVICE_CONFIG) const shared = ctx.sessions.create(sharedHeader.id, { meta: { createdAt: 3 } }) shared.append('session/title', { title: 'Live title', @@ -525,6 +530,7 @@ describe('session-query exact reads', () => { { meta: second, events: [titleEvent('Second title', 20)] }, ]) const ctx = await liveContext() + await ctx.plugin(SessionTitleService, TITLE_SERVICE_CONFIG) await ctx.plugin(TestPersistence) const signal = new AbortController().signal const missing = SessionId('batch-title-missing') @@ -748,6 +754,7 @@ describe('session-query exact reads', () => { { meta: malformed, events: [malformedTitle] }, ]) const ctx = await liveContext() + await ctx.plugin(SessionTitleService, TITLE_SERVICE_CONFIG) await ctx.plugin(TestPersistence) TestPersistence.inspectOverride = (id) => { if (id === failed.id) return Promise.reject(inspectFailure) @@ -784,7 +791,7 @@ describe('session-query exact reads', () => { }) expect(results[2]).toMatchObject({ sessionId: malformed.id, status: 'rejected' }) if (results[2]?.status !== 'rejected') throw new Error('expected malformed title rejection') - expect(results[2].reason).toBeInstanceOf(TypeError) + expect(results[2].reason).toBeInstanceOf(Error) }) it('preserves live batch results across missing persistence, listing failure, and late attachment', async () => { @@ -1192,6 +1199,7 @@ describe('session-query exact reads', () => { it('leaves the optional persistence dependency optional', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(TestSessionQueryEngine) expect(ctx.sessionQuery).toBeInstanceOf(TestSessionQueryEngine) await fiber.dispose() @@ -1202,6 +1210,7 @@ describe('session-query exact reads', () => { TestPersistence.reset() const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const query = await ctx.plugin(TestSessionQueryEngine) const persistence = await ctx.plugin(TestPersistence) const optional = (ctx.sessionQuery as unknown as { diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 0bff6c1154..5a38eec21f 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -2,6 +2,7 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence from '@deepseek-ai/dsh-session-persistence' import { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' @@ -99,6 +100,7 @@ class TracePersistence extends SessionPersistence { async function queryContext(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQueryEngine) return ctx } diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 8c069a5907..3b9fea73b1 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -39,7 +39,9 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" @@ -58,6 +60,8 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^" } } diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index d204184cfe..c36355d728 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -17,7 +17,7 @@ import { presentation } from './presentation.ts' export const name = 'tool-session-query' /** Capability services required by the model-facing consumer. */ -export const inject = ['tools', 'systemPrompt', 'sessionQuery'] +export const inject = ['tools', 'systemPrompt', 'sessionQuery', 'sessionProjections'] /** Default maximum number of authorized search hits returned by one call. */ export const DEFAULT_MAX_SEARCH_RESULTS = 100 diff --git a/packages/session-query/tool-session-query/src/operations.ts b/packages/session-query/tool-session-query/src/operations.ts index a8010c0e90..88107783cb 100644 --- a/packages/session-query/tool-session-query/src/operations.ts +++ b/packages/session-query/tool-session-query/src/operations.ts @@ -57,7 +57,7 @@ async function executeSessionSearch( exec: ToolRunContext, maxResults: number, ): Promise { - const caller = workspaceAccess.callerOf(exec) + const caller = workspaceAccess.callerOf(exec, ctx) const cwd = caller.header.cwd if (cwd === undefined) { throw new HarnessError( @@ -119,20 +119,22 @@ async function executeEventSearch( exec: ToolRunContext, maxResults: number, ): Promise { - const caller = workspaceAccess.callerOf(exec) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const query = toolInput.normalizeQuery(args.query) const range = toolInput.sequenceRange(args.seq_from, args.seq_to) if (sessionId === caller.id) { - const stepStart = caller.events.findLast(event => event.type === 'step/start') - if (stepStart === undefined) { + const stepStartSeq = caller.boundary?.lastStepStartSeq + if (stepStartSeq === undefined) { throw new HarnessError( 'current-session search requires an active step boundary', 'SESSION_QUERY_TOOL_NO_CURRENT_STEP', ) } - range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1) + // `null` (no step started yet) caps the range to a degenerate `to` the + // filter validation rejects — the loop never runs tools outside a step. + range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) } const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) if (range.from !== undefined && range.to !== undefined && range.from > range.to) { @@ -170,7 +172,7 @@ async function executeSessionTrace( args: SessionTargetArgs, exec: ToolRunContext, ): Promise { - const caller = workspaceAccess.callerOf(exec) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => @@ -203,7 +205,7 @@ async function executeEventTrace( exec: ToolRunContext, ): Promise { toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const caller = workspaceAccess.callerOf(exec) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => @@ -221,7 +223,7 @@ async function executeEventRead( toolInput.assertNonNegativeSafeInteger('seq', args.seq) if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) - const caller = workspaceAccess.callerOf(exec) + const caller = workspaceAccess.callerOf(exec, ctx) const sessionId = workspaceAccess.targetId(args, caller) await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts index a6c7b1ce40..08d07d500b 100644 --- a/packages/session-query/tool-session-query/src/workspace-access.ts +++ b/packages/session-query/tool-session-query/src/workspace-access.ts @@ -12,17 +12,21 @@ import { type SessionHeader, type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' +import type { TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' import type { SessionLineageNode, SessionRecord, } from '@deepseek-ai/dsh-session-query' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-session-projection' import { serviceBoundary } from './service-boundary.ts' interface Caller { readonly id: SessionIdValue readonly header: SessionHeader readonly events: readonly SessionEvent[] + /** The caller's own-session boundary fold (the `turnBoundary` projection). */ + readonly boundary: TurnBoundaryProjection | undefined } interface TitleView { @@ -51,7 +55,7 @@ interface DescendantVisit { readonly next: DescendantVisit | undefined } -function callerOf(exec: ToolRunContext): Caller { +function callerOf(exec: ToolRunContext, ctx: Context): Caller { const agent = exec.agent if (agent === undefined) { throw new HarnessError( @@ -63,6 +67,7 @@ function callerOf(exec: ToolRunContext): Caller { id: agent.session.id, header: agent.session.header, events: agent.session.events, + boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'), } } diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index 500d7de3a6..d585bfd270 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -10,6 +10,8 @@ import SessionStore, { SessionId, type Session, } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -30,6 +32,10 @@ function fakeAgent(session: Session): Agent { return { id: session.id, session } as unknown as Agent } +function registerTurnBoundary(ctx: Context): void { + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +} + describe('tool-session-query with the real SQLite provider', () => { it('searches live prior-step history and a persisted same-workspace log', { timeout: 20_000 }, async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-tool-session-query-')) @@ -37,6 +43,8 @@ describe('tool-session-query with the real SQLite provider', () => { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) @@ -106,6 +114,8 @@ describe('tool-session-query with the real SQLite provider', () => { const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 7d213f9591..25923c9247 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -11,6 +11,8 @@ import SessionStore, { type SessionHeader, type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionQueryEngine, { SessionQueryError, SessionSearchCursor, @@ -195,6 +197,10 @@ interface Mounted { call(name: string, args: unknown, options?: { agent?: Agent; signal?: AbortSignal }): Promise } +function registerTurnBoundary(ctx: Context): void { + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +} + async function mount( config: ToolSessionQuery.Config = {}, callerCwd: string | null = '/work', @@ -203,6 +209,8 @@ async function mount( const ctx = new Context() activeContexts.push(ctx) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) if (enforceTimeout) await ctx.plugin(TimeoutPolicy) @@ -1612,7 +1620,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => { query: 'q' }, { agent: fakeAgent(noStep) }, ) - expect(errorCode(missing)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP') + expect(errorCode(missing)).toBe('SESSION_QUERY_INVALID_FILTER') const other = createSession(mounted.ctx, 'paged-events', '/work') const cursor = SessionSearchCursor('events-next') @@ -1634,6 +1642,26 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => expect(text(result)).toContain('Result cap reached') }) + it('rejects current-session search without the turnBoundary fold', async () => { + const ctx = new Context() + activeContexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(FakeQuery) + await ctx.plugin(ToolSessionQuery) + const session = createSession(ctx, 'no-boundary-caller', '/work') + const result = await ctx.tools.execute({ + name: 'session_event_search', + arguments: { query: 'q' }, + callId: CallId('call-no-boundary'), + signal: new AbortController().signal, + agent: fakeAgent(session), + }) + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP') + }) + it('preserves base results when a title read fails, annotates the code, and logs the full error', async () => { const mounted = await mount() const hit = createSession(mounted.ctx, 'hit', '/work') diff --git a/packages/session-query/tool-session-query/tsconfig.json b/packages/session-query/tool-session-query/tsconfig.json index d3ef530901..d9accb9886 100644 --- a/packages/session-query/tool-session-query/tsconfig.json +++ b/packages/session-query/tool-session-query/tsconfig.json @@ -4,7 +4,9 @@ "rootDir": "src", "outDir": "lib/types" }, - "include": ["src"], + "include": [ + "src" + ], "references": [ { "path": "../../../vendor/cosmokit" @@ -35,6 +37,12 @@ }, { "path": "../../util/timeout" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../core/agent" } ] } diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 1273bf3ce3..6479295b12 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -50,6 +50,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts index 9557746db9..4be7094ee5 100644 --- a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts +++ b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts @@ -2,6 +2,7 @@ import { writeFile } from 'node:fs/promises' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage, CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -37,6 +38,7 @@ class CrashAdapter extends LlmAdapter { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) +await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' }) await ctx.plugin(checkpointPolicy) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index c1f659857b..a6f003b550 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.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 packages/session/session-projection/README.md -README.md: 7ef1daedad2790bac31c48315b322f19914a29af -README.zh.md: 928a2ea88fbc0c8e8fd3398fb0c87a7a8ed3b6e7 +README.md: bbdb9fc172bc3be36a7594772eeef5111f922eaf +README.zh.md: 058fa6da0b1d43a438552d2c554e9f968d50ebb6 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 7ef1daedad..bbdb9fc172 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -27,7 +27,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - **Synchronous unit discipline.** `init`/`apply`/`wire.view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally async view returns a Promise, which fails `wire.viewSchema.parse`. - **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Client-visible units persist automatically; a host-only unit opts in with `persist: true`. - **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them. -- **Optional capability.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent. +- **Required for units and host reads.** Plugins that contribute or read projection units inject `sessionProjections`, so incomplete compositions fail during activation. The lower-level api-proxy factory remains tolerant for isolated tests and diagnostics and omits projection blocks and frames when the registry is absent. ## Role diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 928a2ea88f..058fa6da0b 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -27,7 +27,7 @@ - **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 - **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。client-visible 单元自动持久化;host-only 单元以 `persist: true` 选择持久化。 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 -- **可选能力。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。 +- **单元与 host 读取方必需。** 贡献或读取投影单元的插件注入 `sessionProjections`,因此不完整的组合会在激活时失败。较低层的 api-proxy factory 仍对隔离测试和诊断保持容错,注册表缺席时省略投影块与帧。 ## 职责 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index aa785cd996..d9d758c5ae 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -176,9 +176,9 @@ interface Registration { * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the * calling fiber): an unloaded domain plugin's key disappears from snapshots - * and clients read it as capability absence. Domain - * plugins register under `ctx.inject(['sessionProjections'], …)` so headless - * assemblies without the registry stay unaffected. Registrants sharing a key + * and clients read it as capability absence. Unit contributors and host readers + * require this service, so incomplete compositions fail during activation. + * Registrants sharing a key * share one unit and are counted: the same tool package mounted in N agent * presets registers N times, and the key survives until the last one * unloads. diff --git a/packages/session/session-stats/README.i18n.yaml b/packages/session/session-stats/README.i18n.yaml index d12db01c69..43d5996252 100644 --- a/packages/session/session-stats/README.i18n.yaml +++ b/packages/session/session-stats/README.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 packages/session/session-stats/README.md -README.md: 81b0de17e335b67936afd6fb11f15411beee3b76 -README.zh.md: 606628ea09bc34203a5374e49db62c1646293846 +README.md: bb6e6611d53678a900f48b4a9f7341aa73072015 +README.zh.md: 8cfb95cba7b73a770b876b9de0509ef9aa999378 diff --git a/packages/session/session-stats/README.md b/packages/session/session-stats/README.md index 81b0de17e3..bb6e6611d5 100644 --- a/packages/session/session-stats/README.md +++ b/packages/session/session-stats/README.md @@ -21,7 +21,7 @@ Function plugin registering the `sessionStats` projection unit: whole-log conver name: '@deepseek-ai/dsh-session-stats' ``` -Injects `sessionProjections` — the plugin's whole purpose; in assemblies without the registry the fiber stays pending and nothing registers. +Requires `sessionProjections` — the plugin's whole purpose — so an incomplete assembly fails during activation. ## Model Experience diff --git a/packages/session/session-stats/README.zh.md b/packages/session/session-stats/README.zh.md index 606628ea09..8cfb95cba7 100644 --- a/packages/session/session-stats/README.zh.md +++ b/packages/session/session-stats/README.zh.md @@ -21,7 +21,7 @@ name: '@deepseek-ai/dsh-session-stats' ``` -注入 `sessionProjections`——这是插件的全部用途;在没有 registry 的装配中 fiber 保持挂起,不注册任何内容。 +要求注入 `sessionProjections`——这是插件的全部用途;不完整的装配会在激活时失败。 ## 模型体验 diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 6287d55029..e35f9d3f8d 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -43,6 +43,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-llm": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^" } } diff --git a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts index c1f51c654b..8c358bfc35 100644 --- a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts +++ b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest' import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService from '@deepseek-ai/dsh-session-title' import * as providerPlugin from '@deepseek-ai/dsh-session-title-all-prompts-llm' @@ -44,6 +46,8 @@ describe('all-messages LLM title provider', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter() ctx.llm.registerAdapter(['current-route'], adapter) diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index a11c796906..1c7b8419cf 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -46,6 +46,8 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-llm": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^" } } diff --git a/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts b/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts index 12ff310ae9..7d003ecb73 100644 --- a/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts +++ b/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts @@ -9,6 +9,7 @@ import { pathToFileURL } from 'node:url' import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService from '@deepseek-ai/dsh-session-title' import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm' @@ -38,6 +39,7 @@ async function loadComposition(): Promise { await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-session-title'", ' config:', ' fallbackMaxWords: 5', @@ -62,6 +64,7 @@ async function loadComposition(): Promise { const modules = new Map([ ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-session-title', SessionTitleService], ['@deepseek-ai/dsh-session-title-first-prompt-llm', providerPlugin], ]) diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts index 0bcdc98535..3b16d7349b 100644 --- a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts +++ b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts @@ -5,6 +5,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionTitleService from '@deepseek-ai/dsh-session-title' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as FirstMessageTitleProvider from '@deepseek-ai/dsh-session-title-first-prompt-llm' const contexts: Context[] = [] @@ -20,6 +21,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-prompt title provider with await ctx.plugin(LlmRuntime) await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' }) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts index c4fae95c87..083682bfa3 100644 --- a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts +++ b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts @@ -3,6 +3,8 @@ import { describe, expect, it, vi } from 'vitest' import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService, { type SessionTitleProvider } from '@deepseek-ai/dsh-session-title' import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm' @@ -36,6 +38,8 @@ describe('first-prompt LLM title provider', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) let registered: SessionTitleProvider | undefined vi.spyOn(ctx.sessionTitle, 'register').mockImplementation((provider) => { @@ -55,6 +59,8 @@ describe('first-prompt LLM title provider', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter() ctx.llm.registerAdapter(['title-route'], adapter) diff --git a/packages/session/session-title/README.i18n.yaml b/packages/session/session-title/README.i18n.yaml index bbe1cfc651..c3210ca41a 100644 --- a/packages/session/session-title/README.i18n.yaml +++ b/packages/session/session-title/README.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 packages/session/session-title/README.md -README.md: e923bd9700180214f235c4971d4b020565bfee43 -README.zh.md: 8e84ac27d9f1509c8530b838eeb5eb2216d87199 +README.md: 7949dbdb73d33c6c7ba1cccd469a6b22732aa95d +README.zh.md: 24014485050be9e6aa736ac6c686416c3778ac10 diff --git a/packages/session/session-title/README.md b/packages/session/session-title/README.md index e923bd9700..7949dbdb73 100644 --- a/packages/session/session-title/README.md +++ b/packages/session/session-title/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event; `foldSessionTitle()` and `ctx.sessionTitle.get()` select the latest event and return its event seq and timestamp. +Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event. The required `title` projection retains the full latest snapshot for `ctx.sessionTitle.get()` while its client view remains `string | null`; `foldSessionTitle()` remains the direct fold for detached logs. Only text blocks from human `user/message` events are eligible. The first eligible prompt schedules a fallback from its first words within the configured UTF-8 byte limit. Whitespace is normalized, terminal control sequences are removed, and truncation never splits a code point. Empty and non-text prompts wait for later eligible input. @@ -13,6 +13,8 @@ Only text blocks from human `user/message` events are eligible. The first eligib - `rename(session, title)` accepts an explicit user title synchronously: it normalizes the text, supersedes in-flight automatic work, and appends a `session/title` event with the `user` source. A user-sourced latest title pins the session — later user messages schedule no automatic revision; an explicit `refresh` remains the deliberate unpin. - `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register. +The service also registers the host-only `titleInput` projection. It incrementally retains eligible human text and the latest request route, so provider scheduling reads typed state through `stateOf()` instead of rescanning the session log. + Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. Forks inherit title events in their seed unchanged. The first-prompt cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt. diff --git a/packages/session/session-title/README.zh.md b/packages/session/session-title/README.zh.md index 8e84ac27d9..2401448505 100644 --- a/packages/session/session-title/README.zh.md +++ b/packages/session/session-title/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -由日志支持的会话标题,提供即时确定性回退与一个可选异步提供方。每次已接受的修订都是仅写入日志的 `session/title` 事件;`foldSessionTitle()` 与 `ctx.sessionTitle.get()` 会选择最新事件,并返回其事件 seq 和时间戳。 +由日志支持的会话标题,提供即时确定性回退与一个可选异步提供方。每次已接受的修订都是仅写入日志的 `session/title` 事件。必需的 `title` 投影为 `ctx.sessionTitle.get()` 保留完整最新快照,而客户端视图仍是 `string | null`;`foldSessionTitle()` 继续用于直接折叠脱离服务的日志。 只有用户 `user/message` 事件中的文本块符合条件。第一条符合条件的提示词会安排回退,从其开头若干词生成标题,并受所配置 UTF-8 字节上限约束。系统会规范化空白、移除终端控制序列,且截断绝不会切断码点。空提示词和非文本提示词会等待后续符合条件的输入。 @@ -13,6 +13,8 @@ - `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。用户来源的最新标题会钉住该会话——后续用户消息不再安排自动修订;显式 `refresh` 仍是有意的解钉手段。 - `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;对提供方执行 dispose(资源释放)会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。 +服务还会注册仅供 host 使用的 `titleInput` 投影。该投影增量保留符合条件的人类文本和最近一次请求 route,使提供方调度可以通过 `stateOf()` 读取类型化状态,无需重新扫描会话日志。 + 自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使请求头未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会立即观察到该事件,并在常规生命周期检查点完成刷写;标题发布本身不会强制刷写。自动失败会发出警告并保留最新标题。新的全消息修订、提供方 dispose、会话 dispose 和显式刷新都会中止旧工作,陈旧的完成结果无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务与内置模型提供方各自追加自己的字面事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算完成。 fork 出的会话会原样继承种子中的标题事件。首消息节奏不会自动为子会话重新生成标题;全消息节奏可以在子会话收到后续用户提示词后追加新修订。 diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 3395816244..352f977661 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -46,7 +46,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^", @@ -60,6 +61,8 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^" } } diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 5ed0f1ca4d..7f467e6d79 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -6,26 +6,38 @@ import { Context, FiberState, Service, type Fiber } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' -import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ZodType } from 'zod' import { assertNever, deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, } from '@deepseek-ai/dsh-session' -// Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' -// The `title` projection-key declaration lives in src/types.ts (its one home); -// this re-export projects the type face onto the package root AND keeps the -// module edge in the emitted index.d.ts, so aggregate programs consuming the -// declarations still receive the SessionProjectionMap merge. -export type * from './types.ts' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-agent' +export type { + SessionTitleEventData, + SessionTitleModelProvenance, + SessionTitleSnapshot, + SessionTitleSource, + SessionTitleUserMessage, + TitleProjection, +} from './types.ts' import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts' +import type { + SessionTitleEventData, + SessionTitleModelProvenance, + SessionTitleSnapshot, + SessionTitleSource, + SessionTitleUserMessage, + TitleInputChunk, + TitleInputState, + TitleProjection, +} from './types.ts' -export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts' - -/** Identifies one session-title provider registration. */ -export type SessionTitleProviderId = Branded<'SessionTitleProviderId'> +/** Branded session-title provider identity. */ +export type SessionTitleProviderId = import('./types.ts').SessionTitleProviderId /** * Brand a raw provider id. @@ -36,44 +48,7 @@ export function SessionTitleProviderId(id: string): SessionTitleProviderId { return id as SessionTitleProviderId } -/** Exact auxiliary model route that produced a title. */ -export interface SessionTitleModelProvenance { - /** Registered LLM provider route. */ - readonly provider: string - /** Provider model id. */ - readonly model: string -} - -/** Durable ownership record for an accepted session title. */ -export type SessionTitleSource = - | { readonly kind: 'fallback' } - | { - readonly kind: 'provider' - readonly provider: SessionTitleProviderId - readonly model?: SessionTitleModelProvenance - } - | { - /** Explicit user rename: pins the title — automatic generation stops scheduling. */ - readonly kind: 'user' - } - -/** Payload of the log-only `session/title` event. */ -export interface SessionTitleEventData { - /** Normalized non-empty title text. */ - readonly title: string - /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ - readonly messageSeqs: number[] - /** Whether the built-in fallback, a registered provider, or the user supplied the title. */ - readonly source: SessionTitleSource -} - -/** Latest folded title plus the title event's durable envelope facts. */ -export interface SessionTitleSnapshot extends SessionTitleEventData { - /** Seq of the latest `session/title` event. */ - readonly eventSeq: number - /** Timestamp of the latest `session/title` event. */ - readonly updatedAt: number -} +export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts' /** Required deterministic fallback and accepted-title limits. */ export interface Config { @@ -111,14 +86,6 @@ export class SessionTitleInvalidError extends Error { override readonly name = 'SessionTitleInvalidError' } -/** One eligible human text message exposed to title providers. */ -export interface SessionTitleUserMessage { - /** Source `user/message` event seq. */ - readonly seq: number - /** Exact concatenated text-block content. */ - readonly text: string -} - /** Automatic generation cadence owned by a registered provider. */ export type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts' @@ -158,48 +125,17 @@ export interface SessionTitleProvider { generate(request: SessionTitleProviderRequest): Promise } -/** - * Collect human text-bearing user messages in log order. - * @param events - session log or persisted replay. - * @param throughSeq - optional inclusive event boundary. - * @returns eligible messages with exact source seqs. - */ -export function collectSessionTitleMessages( - events: readonly SessionEvent[], - throughSeq?: number, -): SessionTitleUserMessage[] { - const messages: SessionTitleUserMessage[] = [] - for (const event of events) { - if (throughSeq !== undefined && event.seq > throughSeq) break - if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue - const content = event.data.content - const text = content - .filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text') - .map(block => block.text) - .join('\n') - if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue - messages.push({ seq: event.seq, text }) - } - return messages +/** Extract one eligible human text message from a session event. */ +function sessionTitleUserMessageOf(event: SessionEvent): SessionTitleUserMessage | undefined { + if (event.type !== 'user/message' || event.data.source.kind !== 'user') return undefined + const content = event.data.content + const text = content + .filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text') + .map(block => block.text) + .join('\n') + if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) return undefined + return { seq: event.seq, text } } - -/** - * Fold the latest logged title without consulting mutable metadata. - * @param events - live or persisted session log. - * @returns the latest immutable title snapshot, or `undefined`. - */ -export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined { - const event = events.findLast(item => item.type === 'session/title') - if (event === undefined) return undefined - return deepFreeze({ - title: event.data.title, - messageSeqs: [...event.data.messageSeqs], - source: copySessionTitleSource(event.data.source), - eventSeq: event.seq, - updatedAt: event.time, - }) -} - /** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */ function copySessionTitleSource(source: SessionTitleSource): SessionTitleSource { switch (source.kind) { @@ -257,9 +193,94 @@ function assertPositiveInteger(name: keyof Config, value: number): void { } } +/** The title unit's folded state — the projection's full public face. */ +export type TitleUnitState = TitleProjection + +/** + * Convert title projection state into an immutable snapshot. + * @param state - the title unit's folded state. + * @returns the immutable snapshot. + */ +export function titleSnapshotFromState(state: TitleUnitState): SessionTitleSnapshot { + return deepFreeze({ + title: state.title, + messageSeqs: [...state.messageSeqs], + source: copySessionTitleSource(state.source), + eventSeq: state.eventSeq, + updatedAt: state.updatedAt, + }) +} + +const TITLE_INPUT_CHUNK_SIZE = 64 + +const EMPTY_TITLE_INPUT: TitleInputState = { first: null, last: null, count: 0, tail: null } + +function titleInputPrefix(state: TitleInputState, throughSeq: number): SessionTitleUserMessage[] { + const chunks: TitleInputChunk[] = [] + for (let chunk = state.tail; chunk !== null; chunk = chunk.previous) chunks.push(chunk) + const prefix: SessionTitleUserMessage[] = [] + for (const chunk of chunks.reverse()) { + for (const message of chunk.messages) { + if (message.seq <= throughSeq) prefix.push(message) + } + } + return prefix +} + +// Zod cannot express the branded provider id without a runtime transform. +const titleProjectionSchema = zod.object({ + title: zod.string().min(1), + messageSeqs: zod.array(zod.number().int().nonnegative()), + source: zod.discriminatedUnion('kind', [ + zod.object({ kind: zod.literal('fallback') }), + zod.object({ + kind: zod.literal('provider'), + provider: zod.string(), + model: zod.object({ provider: zod.string(), model: zod.string() }).optional(), + }), + zod.object({ kind: zod.literal('user') }), + ]), + eventSeq: zod.number().int().nonnegative(), + updatedAt: zod.number(), +}).nullable() as unknown as ZodType + +const titleViewSchema: ZodType = zod.string().min(1).nullable() + +/** Latest logged title and its client view. */ +export const titleProjectionDefinition = { + key: 'title', + stateVersion: 2, + stateSchema: titleProjectionSchema, + init: () => null, + apply: (state, event) => (event.type === 'session/title' + ? { + title: event.data.title, + messageSeqs: event.data.messageSeqs, + source: event.data.source, + eventSeq: event.seq, + updatedAt: event.time, + } + : state), + wire: { + viewSchema: titleViewSchema, + view: state => state?.title ?? null, + }, +} satisfies ProjectionDefinition<'title', TitleUnitState | null> + +/** + * Fold the latest title from a session log. + * @param events - live or persisted session log. + * @returns the immutable latest title snapshot, or `undefined`. + */ +export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined { + let state: TitleUnitState | null = titleProjectionDefinition.init() + for (const event of events) state = titleProjectionDefinition.apply(state, event) + return state === null ? undefined : titleSnapshotFromState(state) +} + /** Log-backed title fold plus asynchronous fallback generation. */ export class SessionTitleService extends Service { - static inject = ['sessions'] + static inject = ['sessions', 'sessionProjections'] static Config: z = z.object({ fallbackMaxWords: z.number().step(1).min(1).required(), fallbackMaxBytes: z.number().step(1).min(1).required(), @@ -301,20 +322,26 @@ export class SessionTitleService extends Service { this.work.clear() }, 'sessionTitle lifecycle') - // The title projection unit: pure last-wins fold of session/title events - // (the same events foldSessionTitle consumes), serving the plain title - // string clients list rows read. The unit child activates only when a - // projection registry is composed (headless assemblies stay unaffected). - ctx.inject(['sessionProjections'], (projectionCtx) => { - const titleSchema = zod.union([zod.string().min(1), zod.null()]) - projectionCtx.sessionProjections.register<'title', string | null>({ - key: 'title', - stateSchema: titleSchema, - init: () => null, - apply: (state, event) => (event.type === 'session/title' ? event.data.title : state), - wire: { viewSchema: titleSchema, view: state => state }, - stateVersion: 1, - }) + ctx.sessionProjections.register(titleProjectionDefinition) + + ctx.sessionProjections.register<'titleInput', TitleInputState>({ + key: 'titleInput', + stateVersion: 1, + stateSchema: zod.custom(), + init: () => EMPTY_TITLE_INPUT, + apply: (state, event) => { + const message = sessionTitleUserMessageOf(event) + if (message === undefined) return state + const tail = state.tail === null || state.tail.messages.length >= TITLE_INPUT_CHUNK_SIZE + ? { messages: [message], previous: state.tail } + : { messages: [...state.tail.messages, message], previous: state.tail.previous } + return { + first: state.first ?? message, + last: message, + count: state.count + 1, + tail, + } + }, }) ctx.on('session/event', (session, event) => { @@ -347,7 +374,8 @@ export class SessionTitleService extends Service { * @returns latest title snapshot, or `undefined` before eligible input. */ get(session: Session): SessionTitleSnapshot | undefined { - return foldSessionTitle(session.events) + const state = this.ctx.sessionProjections.stateOf(session, 'title') + return state === null || state === undefined ? undefined : titleSnapshotFromState(state) } /** @@ -397,15 +425,15 @@ export class SessionTitleService extends Service { throw new Error(`session "${session.id}" is not live in this store`) } const registration = this.registration - const messages = collectSessionTitleMessages(session.events) - const latest = messages.at(-1) - if (registration === undefined || registration.closing || latest === undefined) { + const input = this.titleInputOf(session) + const latest = input.last + if (registration === undefined || registration.closing || latest === null) { // Explicit refresh is the unpin even without a provider: a standing // user title must not short-circuit ensureFallback into a no-op, so // re-derive and append the fallback over it when one is derivable. const current = this.get(session) - const [first] = messages - if (current?.source.kind === 'user' && first !== undefined) { + const first = input.first + if (current?.source.kind === 'user' && first !== null) { this.appendFallback(session, first) signal?.throwIfAborted() return this.get(session) @@ -462,14 +490,14 @@ export class SessionTitleService extends Service { /** Schedule fallback creation and any provider cadence for one eligible event. */ private onUserMessage(session: Session, event: Extract): void { if (!this.serviceActive()) return - if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return + if (event.data.source.kind !== 'user' || sessionTitleUserMessageOf(event) === undefined) return // A user rename pins the title: no automatic revision may override it. if (this.get(session)?.source.kind === 'user') return const registration = this.registration if (registration !== undefined && !registration.closing) { - const messages = collectSessionTitleMessages(session.events, event.seq) + const count = this.titleInputOf(session).count const shouldSchedule = registration.provider.automatic === 'all-prompts' - || (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined) + || (session.header.parentSession === undefined && count === 1 && this.get(session) === undefined) if (shouldSchedule) { const state = this.stateFor(session) const revision = this.supersede(state, 'newer user message superseded title generation') @@ -506,9 +534,9 @@ export class SessionTitleService extends Service { const state = session === undefined ? undefined : this.work.get(session) const pending = state?.pending if (session === undefined || state === undefined || pending === undefined) return - const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end') + const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.lastStepBoundary const route = session.requestHeader()?.config - if (boundary?.type !== 'step/start' + if (boundary?.kind !== 'start' || boundary.seq <= pending.throughSeq || route?.provider !== options.provider || route.model !== options.model) return @@ -558,7 +586,7 @@ export class SessionTitleService extends Service { this.assertCurrent(session, work) await this.ensureFallback(session) this.assertCurrent(session, work) - const messages = collectSessionTitleMessages(session.events, work.throughSeq) + const messages = titleInputPrefix(this.titleInputOf(session), work.throughSeq) const result = await work.registration.provider.generate({ session, messages, @@ -680,6 +708,11 @@ export class SessionTitleService extends Service { return state } + private titleInputOf(session: Session): TitleInputState { + /* v8 ignore next -- session-title registers its own titleInput unit, so the key is always present */ + return this.ctx.sessionProjections.stateOf(session, 'titleInput') ?? EMPTY_TITLE_INPUT + } + /** Queue detached service work and retain it through service disposal. */ private defer(task: () => Promise): void { const run = Promise.resolve().then(async () => { @@ -757,8 +790,8 @@ export class SessionTitleService extends Service { this.assertServiceActive() const current = this.get(session) if (current !== undefined) return current - const [first] = collectSessionTitleMessages(session.events) - if (first === undefined) return undefined + const first = this.titleInputOf(session).first + if (first === null) return undefined const title = fallbackSessionTitle( first.text, this.config.fallbackMaxWords, diff --git a/packages/session/session-title/src/types.ts b/packages/session/session-title/src/types.ts index ad93926202..77c6d3e78e 100644 --- a/packages/session/session-title/src/types.ts +++ b/packages/session/session-title/src/types.ts @@ -2,19 +2,93 @@ * Pure types of the title domain: the ONE home of the `title` projection-key * declaration, free of this package's host-side value imports (cordis * service, schemastery, the llm seam). Two namespace projections serve it — - * `./types` for host consumers, `./client/types` (the browser half-entry's - * re-export) for client aggregates — with zero content duplication. + * `./types` for host consumers and `./client/types` for client aggregates — + * with zero content duplication. * * @module @deepseek-ai/dsh-session-title/types */ -// Marks this file a module so the declaration below AUGMENTS the projection -// table instead of declaring an ambient module. export {} +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Identifies one session-title provider registration. */ +export type SessionTitleProviderId = Branded<'SessionTitleProviderId'> + +/** Exact auxiliary model route that produced a title. */ +export interface SessionTitleModelProvenance { + /** Registered LLM provider route. */ + readonly provider: string + /** Provider model id. */ + readonly model: string +} + +/** Durable ownership record for an accepted session title. */ +export type SessionTitleSource = + | { readonly kind: 'fallback' } + | { + readonly kind: 'provider' + readonly provider: SessionTitleProviderId + readonly model?: SessionTitleModelProvenance + } + | { + /** Explicit user rename: pins the title — automatic generation stops scheduling. */ + readonly kind: 'user' + } + +/** Payload of the log-only `session/title` event. */ +export interface SessionTitleEventData { + /** Normalized non-empty title text. */ + readonly title: string + /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */ + readonly messageSeqs: number[] + /** Whether the built-in fallback, a registered provider, or the user supplied the title. */ + readonly source: SessionTitleSource +} + +/** Latest folded title plus the title event's durable envelope facts. */ +export interface SessionTitleSnapshot extends SessionTitleEventData { + /** Seq of the latest `session/title` event. */ + readonly eventSeq: number + /** Timestamp of the latest `session/title` event. */ + readonly updatedAt: number +} + +/** Host title projection value. */ +export type TitleProjection = SessionTitleSnapshot + +/** One eligible human text message exposed to title providers. */ +export interface SessionTitleUserMessage { + /** Source `user/message` event seq. */ + readonly seq: number + /** Exact concatenated text-block content. */ + readonly text: string +} + +/** One bounded reverse-linked group of eligible title-input messages. */ +export interface TitleInputChunk { + readonly messages: readonly SessionTitleUserMessage[] + readonly previous: TitleInputChunk | null +} + +/** Eligible title input stored in bounded reverse-linked chunks. */ +export interface TitleInputState { + /** The oldest eligible message, or null before any. */ + readonly first: SessionTitleUserMessage | null + /** Total eligible messages folded so far. */ + readonly count: number + /** Newest eligible message, or null before any. */ + readonly last: SessionTitleUserMessage | null + /** Newest chunk in the reverse-linked list. */ + readonly tail: TitleInputChunk | null +} + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { - title: string | null + /** Latest logged title, or null. */ + title: TitleProjection | null + /** Eligible human title input. */ + titleInput: TitleInputState } interface SessionProjectionMap { /** diff --git a/packages/session/session-title/tests/persistence.spec.ts b/packages/session/session-title/tests/persistence.spec.ts index a886e62db3..18b5f57aca 100644 --- a/packages/session/session-title/tests/persistence.spec.ts +++ b/packages/session/session-title/tests/persistence.spec.ts @@ -5,6 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite' import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title' @@ -57,6 +58,7 @@ describe('session title persistence round trips', () => { const id = SessionId('title-jsonl') const writer = new Context() await writer.plugin(SessionStore) + await writer.plugin(SessionProjectionRegistry) await writer.plugin(JsonlSessionPersistence, { root, compression: 'none' }) await writer.plugin(SessionTitleService, CONFIG) await appendPersistedTitle(writer, id) @@ -64,6 +66,7 @@ describe('session title persistence round trips', () => { const reader = new Context() await reader.plugin(SessionStore) + await reader.plugin(SessionProjectionRegistry) await reader.plugin(JsonlSessionPersistence, { root, compression: 'none' }) await expectPersistedTitle(reader, id) await reader.fiber.dispose() @@ -76,6 +79,7 @@ describe('session title persistence round trips', () => { const id = SessionId('title-sqlite') const writer = new Context() await writer.plugin(SessionStore) + await writer.plugin(SessionProjectionRegistry) await writer.plugin(SqliteSessionPersistence, { path }) await writer.plugin(SessionTitleService, CONFIG) await appendPersistedTitle(writer, id) @@ -83,6 +87,7 @@ describe('session title persistence round trips', () => { const reader = new Context() await reader.plugin(SessionStore) + await reader.plugin(SessionProjectionRegistry) await reader.plugin(SqliteSessionPersistence, { path }) await expectPersistedTitle(reader, id) await reader.fiber.dispose() diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index 1198e78a23..d518f9f756 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -1,16 +1,6 @@ -/** - * The `title` projection unit: mounting the title service beside the - * projection registry serves the current normalized title (last-wins over - * session/title events, the same events foldSessionTitle consumes) — null - * before the first title — through the registry snapshot and the change - * feed; compositions without the registry are unaffected; unmounting the - * service removes the key (HMR safety). The bespoke session/title mux frame - * is untouched by this unit (its retirement is the client value-store - * migration's concern). - */ - import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -26,7 +16,6 @@ async function harness(withTitleService: boolean): Promise<{ ctx: Context; sessi return { ctx, session: ctx.sessions.create(SessionId('titled')) } } -/** Append one session/title event directly (the replay-plane shape the unit folds). */ function appendTitle(session: Session, title: string): number { return session.append('session/title', { title, messageSeqs: [1], source: { kind: 'fallback' } }).seq } @@ -46,7 +35,6 @@ describe('title projection unit', () => { }) const firstSeq = appendTitle(session, 'First title') const secondSeq = appendTitle(session, 'Second title') - // Unrelated event: same-reference apply, no notification. session.append('turn/start', { turn: 1 }) expect(changes).toEqual([ { key: 'title', value: 'First title', seq: firstSeq }, @@ -73,4 +61,23 @@ describe('title projection unit', () => { await fiber.dispose() expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false) }) + + it('keeps thousands of title inputs in bounded reverse-linked chunks without persisting them', async () => { + const { ctx, session } = await harness(false) + session.append('turn/start', { turn: 1 }) + for (let index = 0; index < 5_000; index++) { + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `message ${String(index)}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + } + await ctx.plugin(SessionTitleService, CONFIG) + + const state = ctx.sessionProjections.stateOf(session, 'titleInput') + expect(state?.count).toBe(5_000) + let chunks = 0 + for (let chunk = state?.tail ?? null; chunk !== null; chunk = chunk.previous) chunks += 1 + expect(chunks).toBe(Math.ceil(5_000 / 64)) + expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeUndefined() + }) }) diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index 7843ff2c22..53fc323d38 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -2,6 +2,8 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import LlmRuntime, { createUserMessage, deepFreeze, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionTitleService, { SessionTitleProviderId, type SessionTitleProvider, @@ -51,6 +53,7 @@ describe('SessionTitleService Provider lifecycle', () => { it('inherits title events across forks, skips first-prompt retitling, and lets all-messages update later', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const parent = ctx.sessions.create(SessionId('title-parent')) parent.append('turn/start', { @@ -115,6 +118,7 @@ describe('SessionTitleService Provider lifecycle', () => { it('runs a first-prompt provider once after the routed request and retries only through refresh', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] const provider: SessionTitleProvider = { @@ -167,9 +171,75 @@ describe('SessionTitleService Provider lifecycle', () => { expect(requests[1]?.messages.map(message => message.seq)).toEqual([first.seq, second.seq]) }) + it('preserves provider input order across bounded title-input chunks', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SessionTitleService, CONFIG) + const session = ctx.sessions.create(SessionId('chunked-provider-input')) + session.append('turn/start', { turn: 1 }) + const messages = Array.from({ length: 70 }, (_, index) => + appendHumanPrompt(session, `Prompt ${String(index)}`)) + await settle() + const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({ + title: 'Chunked history', + messageSeqs: request.messages.map(message => message.seq), + })) + ctx.sessionTitle.register({ + id: SessionTitleProviderId('chunked-provider'), + automatic: 'all-prompts', + generate, + }) + + await ctx.sessionTitle.refresh(session) + + expect(generate).toHaveBeenCalledOnce() + expect(generate.mock.calls[0]?.[0].messages).toEqual(messages.map((message, index) => ({ + seq: message.seq, + text: `Prompt ${String(index)}`, + }))) + }) + + it('cuts a first-message provider request at its scheduled watermark when a newer prompt lands first', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SessionTitleService, CONFIG) + const requests: SessionTitleProviderRequest[] = [] + ctx.sessionTitle.register({ + id: SessionTitleProviderId('watermark-cut'), + automatic: 'first-prompt', + async generate(request) { + requests.push(request) + return { + title: 'Watermarked title', + messageSeqs: request.messages.map(message => message.seq), + } + }, + }) + const session = ctx.sessions.create(SessionId('watermark-cut')) + session.append('turn/start', { + turn: 1, + }) + const first = appendHumanPrompt(session, 'First prompt') + await settle() + appendHumanPrompt(session, 'A newer prompt before the route') + appendRoute(session) + await settle() + + expect(requests).toHaveLength(1) + expect(requests[0]?.messages).toEqual([{ seq: first.seq, text: 'First prompt' }]) + expect(ctx.sessionTitle.get(session)).toMatchObject({ + title: 'Watermarked title', + messageSeqs: [first.seq], + source: { kind: 'provider', provider: SessionTitleProviderId('watermark-cut') }, + }) + }) + it('rejects a second provider and drains stale work when the winner is disposed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const pending = deferred() let observedSignal: AbortSignal | undefined @@ -221,6 +291,7 @@ describe('SessionTitleService Provider lifecycle', () => { it('supersedes an older all-messages revision and cannot commit an ignored abort', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const firstResult = deferred() const requests: SessionTitleProviderRequest[] = [] @@ -264,6 +335,8 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] ctx.sessionTitle.register({ @@ -318,6 +391,8 @@ describe('SessionTitleService Provider lifecycle', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, CONFIG) const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({ title: 'Unexpected title', @@ -349,6 +424,7 @@ describe('SessionTitleService Provider lifecycle', () => { it('contains automatic failures but lets explicit refresh reject', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const provider: SessionTitleProvider = { diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts index 9452ee8dc4..7701d953b7 100644 --- a/packages/session/session-title/tests/rename.spec.ts +++ b/packages/session/session-title/tests/rename.spec.ts @@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, foldSessionTitle, @@ -32,6 +33,7 @@ describe('SessionTitleService.rename', () => { it('appends a normalized user-source title', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-accept')) session.append('turn/start', { turn: 1 }) @@ -57,6 +59,7 @@ describe('SessionTitleService.rename', () => { it('rejects titles that normalize to empty and dead sessions', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-reject')) expect(() => ctx.sessionTitle.rename(session, '  ')).toThrow(/visible characters/) @@ -68,6 +71,7 @@ describe('SessionTitleService.rename', () => { it('pins the title: later user messages schedule no automatic revision; refresh unpins', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({ title: 'Provider title', @@ -105,6 +109,7 @@ describe('SessionTitleService.rename', () => { it('fallback-only refresh also unpins: the user title yields to a re-derived fallback', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('rename-unpin-fallback')) session.append('turn/start', { turn: 1 }) @@ -126,6 +131,7 @@ describe('SessionTitleService.rename', () => { it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) // The provider parks on a test-held deferred so rename lands while its // generation is ACTIVE (not merely scheduled). @@ -165,6 +171,7 @@ describe('SessionTitleService.rename', () => { it('fallback-only refresh keeps the user title when no fallback is derivable', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) // A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the // re-derived fallback is empty, so the pinned title survives the refresh. await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 }) diff --git a/packages/session/session-title/tests/service-contracts.spec.ts b/packages/session/session-title/tests/service-contracts.spec.ts index 521562c4ed..6ac579bd35 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -2,6 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { Context, type Fiber } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, type Config, @@ -29,6 +30,7 @@ async function settle(): Promise { async function setup(config: Config = CONFIG): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, config) return ctx } @@ -241,6 +243,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { it('cancels a queued fallback when the session-title service unloads', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const lifecycle: { fiber?: Fiber; session?: Session; inactiveRefresh?: Promise } = {} ctx.on('internal/plugin', (subject) => { if (subject !== lifecycle.fiber || subject.uid !== null || lifecycle.session === undefined) return @@ -273,6 +276,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { it('suppresses a queued fallback failure after service unload begins', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(SessionTitleService, CONFIG) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const session = startSession(ctx, 'service-unload-started-fallback') @@ -288,6 +292,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { it('aborts pending and active provider work and drains ignored cancellation during service unload', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(SessionTitleService, CONFIG) const result = deferred() const requests: SessionTitleProviderRequest[] = [] diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index bb8ce4625d..905e24aa9b 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -2,6 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionTitleService, { SessionTitleProviderId, fallbackSessionTitle, @@ -39,6 +40,7 @@ describe('SessionTitleService', () => { it('logs and folds an immediate fallback after the first eligible human text message', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('fresh')) session.append('turn/start', { @@ -75,6 +77,7 @@ describe('SessionTitleService', () => { it('derives a fallback title from the direct prompt instead of baked prefix context', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('prefixed-title')) session.append('turn/start', { @@ -93,6 +96,7 @@ describe('SessionTitleService', () => { it('waits through synthetic, empty, and non-text messages, then keeps the first fallback', async () => { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SessionTitleService, CONFIG) const session = ctx.sessions.create(SessionId('eligibility')) session.append('turn/start', { @@ -159,4 +163,10 @@ describe('SessionTitleService', () => { updatedAt: seed.events[1]?.time, }) }) + + it('folds an empty or title-less log to undefined', () => { + expect(foldSessionTitle([])).toBeUndefined() + const empty = Session.create(SessionId('no-title')) + expect(foldSessionTitle(empty.events)).toBeUndefined() + }) }) diff --git a/packages/session/session-title/tsconfig.json b/packages/session/session-title/tsconfig.json index 40937098cb..d4eef17de4 100644 --- a/packages/session/session-title/tsconfig.json +++ b/packages/session/session-title/tsconfig.json @@ -31,6 +31,10 @@ }, { "path": "../session-projection" + }, + { + "path": "../../core/agent" + } ] } diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index e0614889ac..f7aa203673 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/node-addon-landlock-run": "workspace:^" + "@deepseek-ai/node-addon-landlock-run": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/shell/bash-sandbox/tests/bwrap.e2e.ts b/packages/shell/bash-sandbox/tests/bwrap.e2e.ts index 11f8a17272..5b00eb14ac 100644 --- a/packages/shell/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/shell/bash-sandbox/tests/bwrap.e2e.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -42,6 +43,7 @@ async function tempDir(base: string): Promise { async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise { ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) diff --git a/packages/shell/bash-sandbox/tests/landlock.e2e.ts b/packages/shell/bash-sandbox/tests/landlock.e2e.ts index a1f2b3ada7..0160caaa56 100644 --- a/packages/shell/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/shell/bash-sandbox/tests/landlock.e2e.ts @@ -8,6 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -47,6 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false } + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) diff --git a/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts b/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts index dea6a41ec1..d665bb4eb5 100644 --- a/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts @@ -9,6 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' @@ -51,6 +52,7 @@ ${fatalBranch}exec "$@" async function setup(fatalExit?: number): Promise { const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(LocalSandboxProvider, {}) const sandbox = ctx.sandbox as LocalSandboxProvider sandbox.internals = { @@ -68,6 +70,7 @@ async function setup(fatalExit?: number): Promise { async function setupConfiguredRunner(runner: string): Promise { const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(LocalSandboxProvider, { runnerCommand: [runner], runnerFailureSignatures: ['configured-runner: fatal'], diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index 750fb870c3..4029cc4f40 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -11,6 +11,7 @@ import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' @@ -62,6 +63,7 @@ async function setup( } } const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(FakeSandboxProvider) await ctx.plugin(SandboxPolicyService, { ...mode !== undefined ? { mode } : {}, diff --git a/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts b/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts index 136259468c..7f2a1be03f 100644 --- a/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -41,6 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' } + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 65a3863bec..dc40592bff 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts b/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts index 22d65b60a9..258e3d950f 100644 --- a/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts @@ -15,6 +15,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { SandboxPwshExecutor } from '../src/index.ts' @@ -64,6 +65,7 @@ async function setup( } } const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(FakeSandboxProvider) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir }) await ctx.plugin(subprocess) diff --git a/packages/shell/shell/README.i18n.yaml b/packages/shell/shell/README.i18n.yaml index a4e67f3760..013a9e13cb 100644 --- a/packages/shell/shell/README.i18n.yaml +++ b/packages/shell/shell/README.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 packages/shell/shell/README.md -README.md: a15980ead797a73c18dd6d8825a4b38cf845df60 -README.zh.md: 87a7dffdfb28fcf8a58e6254bf450e513dd458f4 +README.md: 6a7c88bb0ad2612b239d68f79c80351a4316c340 +README.zh.md: 476bd793828b068905aec65d1072a9d78c0fa725 diff --git a/packages/shell/shell/README.md b/packages/shell/shell/README.md index a15980ead7..6a7c88bb0a 100644 --- a/packages/shell/shell/README.md +++ b/packages/shell/shell/README.md @@ -33,7 +33,7 @@ Implementations subclass `ShellExecutor` and implement the abstract methods. Dis `ShellExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `ShellExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing. -The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `ShellRunResult`; `start()` returns `ShellProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `ShellSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/shell.md](../../../docs/subsystems/shell.md). +The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `sandboxMode` projection, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `ShellRunResult`; `start()` returns `ShellProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `ShellSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/shell.md](../../../docs/subsystems/shell.md). `stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.shellEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). diff --git a/packages/shell/shell/README.zh.md b/packages/shell/shell/README.zh.md index 87a7dffdfb..476bd79382 100644 --- a/packages/shell/shell/README.zh.md +++ b/packages/shell/shell/README.zh.md @@ -33,7 +33,7 @@ `ShellExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `ShellExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。 -每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `ShellRunResult`;`start()` 返回 `ShellProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `ShellSandboxInfo`。详见 `src/types.ts` 与 [subsystems/shell.md](../../../docs/subsystems/shell.md)。 +每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`sandboxMode` 投影以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `ShellRunResult`;`start()` 返回 `ShellProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `ShellSandboxInfo`。详见 `src/types.ts` 与 [subsystems/shell.md](../../../docs/subsystems/shell.md)。 `stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.shellEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 2232d909e4..c6d671d40f 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 6d6affdb44..878930426c 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -15,6 +15,7 @@ import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -75,6 +76,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { "- name: '@deepseek-ai/dsh-tools'", "- name: '@deepseek-ai/dsh-terminal'", "- name: '@deepseek-ai/dsh-test-sandbox'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-sandbox-policy'", ' config:', ' mode: danger-full-access', @@ -108,6 +110,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { ['@deepseek-ai/dsh-tools', ToolRuntime], ['@deepseek-ai/dsh-terminal', TerminalSessionService], ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime], ['@deepseek-ai/dsh-terminal-bash', TerminalLocal], diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 0de6feef49..78244ea09b 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -68,6 +68,7 @@ "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index 78a40d6962..c645230ac2 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' 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' @@ -26,6 +27,8 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + // AgentLoop declares the registry as a required injection. + await ctx.plugin(SessionProjectionRegistry) if (sessionRoot !== undefined) { await ctx.plugin(JsonlSessionPersistence, { root: sessionRoot, compression: 'none' }) } diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 0f2e4d5a20..acc07a9fcd 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -10,6 +10,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -19,6 +20,7 @@ import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' import { processOutcome } from '../src/background.ts' @@ -188,6 +190,8 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) @@ -201,7 +205,7 @@ function sandboxAgent( ctx?: Context, onAppend?: (type: string) => void, ): Agent { - const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }] + const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start', data: { turn: 1 } }] if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } }) const id = SessionId('sandbox-session') return { diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index e87a546b62..afc3e4a412 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index ecd9f5bde5..9d007dd53f 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -27,6 +27,8 @@ import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' @@ -210,6 +212,11 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) await ctx.plugin(BashEnvPlugin) + await ctx.plugin(SessionProjectionRegistry) + // The loop's turnBoundary unit (the open-turn fold) is not mounted in this + // bench — the loop itself is not composed. Register its open-turn fold so + // the approval service's turn-enclosure gate reads the seeded log shape. + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(ConfiningFakeBash) if (withApproval) await ctx.plugin(ApprovalService) @@ -229,8 +236,10 @@ function sandboxAgent( ctx?: Context, onAppend?: (type: string) => void, ): Agent { - const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }] - if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } }) + const events: Array<{ type: string; data?: Record; seq?: number }> = [ + { type: 'turn/start', seq: 0, data: { turn: 1 } }, + ] + if (mode !== undefined) events.push({ type: 'sandbox/mode', seq: 1, data: { mode } }) const id = SessionId('sandbox-session') return { id, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index cdd1bbfa04..a7cb45ceb3 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -37,10 +37,12 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -51,6 +53,7 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index e2a0cc2dca..ac1bbec5fa 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -7,10 +7,12 @@ import { createHash } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { z as zod } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-projection' import { escapeText, isModelInvocable, @@ -22,7 +24,7 @@ import { } from '@deepseek-ai/dsh-skill' export const name = 'tool-skill' -export const inject = ['agents', 'tools', 'skills'] +export const inject = ['agents', 'tools', 'skills', 'sessionProjections'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 /** @@ -68,12 +70,30 @@ export const Config: z = z.object({ catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH), }) +/** + * The skill-catalog projection's state schema — the one definition of the + * state shape; the type is inferred from it (state equals the public shape). + */ +const skillCatalogStateSchema = zod.object({ + digest: zod.string().min(1), + seq: zod.number().int().nonnegative(), +}).nullable() + +type SkillCatalogState = zod.infer + /** * Register the model-facing skill loader and its visibility-matched * durable session catalog. The catalog is emitted only when the calling agent * resolves this plugin's exact tool registration; a restriction or scoped * same-name shadow therefore removes both the schema and its call guidance. */ +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** The latest published skill-catalog message (digest + seq); null before the first publication. */ + skillCatalog: SkillCatalogState + } +} + export function apply(ctx: Context, config: Config = {}): void { const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3) @@ -203,6 +223,19 @@ export function apply(ctx: Context, config: Config = {}): void { return { kind: 'enter', messages: [...decision.messages, ...injections] } }) + ctx.sessionProjections.register({ + key: 'skillCatalog', + stateVersion: 1, + stateSchema: skillCatalogStateSchema, + init: () => null, + apply: (state, event) => { + if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') return state + const entries = readCatalogEntries(event.data.source) + if (entries === undefined) return state + return { digest: digestCatalogEntries(entries), seq: event.seq } + }, + }) + // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. // @@ -226,7 +259,7 @@ export function apply(ctx: Context, config: Config = {}): void { const skills = snapshot.skills.filter(isModelInvocable) const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength) const digest = digestCatalogEntries(entries) - const history = catalogHistory(agent) + const history = catalogHistory(ctx, agent) const existing = catalogMessage(decision.messages) if (history.visibleDigest === digest) { return existing === undefined @@ -358,22 +391,13 @@ function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | un return readable } -function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { +function catalogHistory(ctx: Context, agent: Agent): { visibleDigest?: string; published: boolean } { const visible = new Set(agent.session.surface.nodes) - const events = agent.session.events - let published = false - for (let index = events.length - 1; index >= 0; index -= 1) { - // The loop bounds prove the read-only event view contains this index. - // oxlint-disable-next-line typescript/no-non-null-assertion - const event = events[index]! - if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue - const entries = readCatalogEntries(event.data.source) - if (entries === undefined) continue - const digest = digestCatalogEntries(entries) - published = true - if (visible.has(event.seq)) return { visibleDigest: digest, published } - } - return { published } + const state = ctx.sessionProjections.stateOf(agent.session, 'skillCatalog') ?? null + if (state === null) return { published: false } + return visible.has(state.seq) + ? { visibleDigest: state.digest, published: true } + : { published: true } } function catalogMessage( diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index d8dd9df2f4..4b63ba8207 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -9,6 +9,7 @@ import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepse import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SkillRegistry from '@deepseek-ai/dsh-skill' import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -30,6 +31,7 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) const home = await tempDir('tool-schema') await ctx.plugin(SkillRegistry) await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) @@ -751,6 +754,7 @@ describe('dsh-tool-skill', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SkillRegistry) await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 8a6fba06e7..f2654b3c9e 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 9654df5feb..607350a29c 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import * as acp from '../src/index.ts' @@ -52,6 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive it('drives the real acp-agent example process to answer a prompt', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -82,6 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive it('drives the child to do real file work via its own bash tool', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index c534b8e949..fd66d9368f 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' @@ -43,6 +44,7 @@ interface SetupEnv { */ async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -222,6 +224,7 @@ describe('cwd resolution', () => { const sentinel = join(tmp, 'spawned') try { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) // A command that would create the sentinel if the child were ever spawned. @@ -241,6 +244,7 @@ describe('cwd resolution', () => { const parentDir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-'))) try { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -269,6 +273,7 @@ describe('cwd resolution', () => { const relative = 'packages/subagent/subagent-acp' const absolute = resolve(relative) const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -289,6 +294,7 @@ describe('cwd resolution', () => { // `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(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await expect(ctx.plugin(acp, { @@ -310,6 +316,7 @@ describe('cwd resolution', () => { chmodSync(tmp, 0o600) try { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await expect(ctx.plugin(acp, { @@ -329,6 +336,7 @@ describe('cwd resolution', () => { it('rejects a config cwd that is not an accessible directory at load', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await expect(ctx.plugin(acp, { @@ -371,6 +379,7 @@ describe('cwd resolution', () => { const sentinel = join(tmp, 'spawned') try { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) @@ -694,6 +703,7 @@ describe('dsh-subagent-acp', () => { const ready = join(tmp, 'trap-armed') try { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -727,6 +737,7 @@ describe('dsh-subagent-acp', () => { { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }, ]) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad })) @@ -737,6 +748,7 @@ describe('dsh-subagent-acp', () => { it('rejects a startup failure via the provider load path', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(acp, { @@ -868,6 +880,7 @@ describe('dsh-subagent-acp', () => { it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} }) diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index fe974b0344..5bffd3de08 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts index 1881e14d33..d9a5c5bc1b 100644 --- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -14,6 +14,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' @@ -112,6 +113,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( } const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const handles: SubprocessHandle[] = [] diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index a8e38885ef..ec227e6f4d 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -21,6 +21,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' @@ -180,6 +181,7 @@ interface RealRuntime { async function realRuntime(): Promise { const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const handles: SubprocessHandle[] = [] diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 8f1ad34d8a..5eedbc0051 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -26,6 +26,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubprocessHandle, SubprocessOutcome, @@ -390,6 +391,7 @@ describe('task admission and package contracts', () => { it('registers the default descriptor, validates config, and unregisters on HMR', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(claudeCode, {}) @@ -421,6 +423,7 @@ describe('task admission and package contracts', () => { it('keeps named instances, runs, and HMR ownership isolated', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const safeChild = fakeChild() @@ -518,6 +521,7 @@ describe('task admission and package contracts', () => { it('rejects duplicate provider names without replacing the first instance', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const firstFiber = await ctx.plugin(claudeCode, { @@ -552,6 +556,7 @@ describe('task admission and package contracts', () => { it('resolves the safe permission default when apply is called directly', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) @@ -561,6 +566,7 @@ describe('task admission and package contracts', () => { it('starts through the registered provider with its resolved config and diagnostics', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const child = fakeChild() diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index dbe30e18e1..2ded5fdc95 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts index c12e96a306..788ea54cde 100644 --- a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -15,6 +15,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' @@ -96,6 +97,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( } const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const handles: SubprocessHandle[] = [] diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 3b95de4f79..459c09d845 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -17,6 +17,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' @@ -120,6 +121,7 @@ interface RealRuntime { async function realRuntime(): Promise { const ctx = new Context() contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const handles: SubprocessHandle[] = [] diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 6ba6f65e87..236db16e60 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessHandle, @@ -399,6 +400,7 @@ describe('task admission and package contracts', () => { it('registers the default descriptor, validates config, and unregisters on HMR', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(codex, {}) @@ -428,6 +430,7 @@ describe('task admission and package contracts', () => { it('keeps named instances, runs, and HMR ownership isolated', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const safeChild = fakeChild() @@ -524,6 +527,7 @@ describe('task admission and package contracts', () => { it('rejects duplicate provider names without replacing the first instance', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const firstFiber = await ctx.plugin(codex, { @@ -556,6 +560,7 @@ describe('task admission and package contracts', () => { it('resolves the safe permission default when apply is called directly', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) @@ -601,6 +606,7 @@ describe('task admission and package contracts', () => { it('requires a parent session cwd without suggesting unsupported config', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const spawn = vi.spyOn(ctx.subprocess, 'spawn') @@ -1796,6 +1802,7 @@ describe('run lifecycle and quiescence', () => { it('uses the registered provider config and logs flattened errors', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(LocalSubprocessRuntime) const child = fakeChild() diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 8db10499cd..ad728161d6 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -55,6 +55,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index f4b5bd4267..5314e5da4c 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -13,6 +13,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import * as sdk from '../src/index.ts' import { @@ -36,6 +37,7 @@ function request(text = 'p', signal = new AbortController().signal) { /** Mount the SDK backend pointed at the fake runtime, scripted by `fakeEnv`. */ async function setup(fakeEnv: Record = {}, config: Partial = {}) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) // The Config type models the post-validation shape, so the default registry // name is stated here; the Loader-composition fixture omits providerName and @@ -361,6 +363,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('registers under the configured provider name and unregisters on fiber dispose (HMR safety)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(sdk, { providerName: 'sdk-hmr', @@ -385,6 +388,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('rejects non-positive timing bounds at load', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const base = { providerName: 'sdk', command: 'true', args: [], provider: 'p', model: 'm', env: {} } await expect(ctx.plugin(sdk, { ...base, shutdownTimeoutMs: 0 })).rejects.toThrow('shutdownTimeoutMs must be a positive finite number') @@ -397,6 +401,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { 'rejects invalid maxTokens %s at load', async (maxTokens) => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.plugin(sdk, { providerName: 'sdk', @@ -415,6 +420,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { 'defensively rejects invalid maxTokens %s when apply is called directly', async (maxTokens) => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) expect(() => { sdk.apply(ctx, { providerName: 'sdk', @@ -434,6 +440,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('rejects an empty config cwd at load', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.plugin(sdk, { providerName: 'sdk', diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index f5f0a8b1e6..1e383410a3 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts index 42e5216e47..f14c5d001b 100644 --- a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts @@ -9,6 +9,7 @@ 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 SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as fork from '../src/index.ts' @@ -37,6 +38,7 @@ async function setup(script: Script) { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(Spawn, { providerName: 'spawn' }) await ctx.plugin(fork, { providerName: 'fork' }) diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index 6d763b7c0a..46c60498a3 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -11,6 +11,7 @@ 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 SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import * as fork from '../src/index.ts' @@ -44,6 +45,7 @@ async function setup(script: Script) { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) @@ -201,6 +203,7 @@ describe('dsh-subagent-fork-in-process', () => { it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(fork, { providerName: 'fork' }) diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index 613eea85ca..593451bf05 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts index aa7437be81..4ff72cba21 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -18,6 +18,7 @@ import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import ApprovalService from '@deepseek-ai/dsh-user-approval' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -40,6 +41,7 @@ async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agen const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }) await ctx.plugin(SandboxedFileSystem, { cwd: workspace }) await ctx.plugin(ToolFs) diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts index b4c5d5736e..93a141375d 100644 --- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts @@ -18,6 +18,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import AgentPresets from '@deepseek-ai/dsh-agent-presets' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -39,6 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; await ctx.plugin(Loader) ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false }) const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts index 8840e8b101..edc4e38846 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -12,6 +12,7 @@ import SubagentRuntime, { type ResolvedSubagentStartRequest, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -67,6 +68,7 @@ async function setup(script: Script, options: SetupOptions = {}) { } await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const disposeProvider = ctx.subagents.registerProvider({ name: 'spawn', diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts index 823890d934..da123ca552 100644 --- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts @@ -10,6 +10,7 @@ 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 SubagentRuntime, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -28,6 +29,7 @@ async function setup(script: Script, parentOptions: Partial = {}) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const adapter = new MockAdapter(script) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 2f317dde8a..a3843f8ff3 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -55,6 +55,7 @@ "@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/subagent-spawn-in-process/tests/harness.ts b/packages/subagent/subagent-spawn-in-process/tests/harness.ts index d60d8057d3..16e6680432 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/harness.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/harness.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' @@ -24,6 +25,7 @@ export async function spawnHarness(workdir: string): Promise { // spawned children render it. It stays neutral for both roles; the // delegation nudge lives in the e2e's user prompt and the subagent tool's // own description. + await ctx.plugin(SessionProjectionRegistry) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: 'You are a coding agent. Report only when the requested work is done.' }, }) diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index 74aefb4ebd..3427a7bf80 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -11,6 +11,7 @@ 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 SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' @@ -38,6 +39,7 @@ async function setup(script: Script) { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -290,6 +292,7 @@ describe('dsh-subagent-spawn-in-process', () => { it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -322,6 +325,7 @@ describe('dsh-subagent-spawn-in-process', () => { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -350,6 +354,7 @@ describe('dsh-subagent-spawn-in-process', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index d140507b44..e5024850ef 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.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 packages/subagent/subagent/README.md -README.md: 99b28fb7fc62473fa02fef4f3977f29e9935edaa -README.zh.md: 6970c8f21424cb73d170602c52fb98641d5d712d +README.md: 7fb8bc9fe7ffa9732b4ae793c43010066e81325f +README.zh.md: f6c4aef102b0771d981d383d238f4bf4733446eb diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 99b28fb7fc..7fb8bc9fe7 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -97,13 +97,13 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen Continuable children do not create `SubagentRun` or Jobs. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` records the sender without granting authority. Interrupt authority is deliberately wider than delivery authority: a human presents the durable direct-parent address so a live child stays stoppable while its parent Agent is offline, and any exact live ancestor recorded in the Activation's materialization lineage may stop its descendant, because stopping a turn is idempotent and delivers no content. -When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws. +The service requires `ctx.sessionProjections` and registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws. `registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately. ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry and maps storage-only to its resumable-not-terminal `ready` (`running`/`idle`/`ready`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: `stateOf()` for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry and maps storage-only to its resumable-not-terminal `ready` (`running`/`idle`/`ready`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; a missing session store fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 6970c8f214..f6c4aef102 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -97,13 +97,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子会话直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 记录发送方,但不授予权限。中断权限被刻意设计得比投递权限更宽:人类出示持久化直接 parent 地址,因此即使 parent Agent 离线,在线 child 仍可被停止;Activation 物化时记录的任何确切在线 ancestor 也可以停止其后代,因为停止一个轮次是幂等的,且不投递任何内容。 -当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本无法识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中的陈旧身份而非继续保留该身份——绝不抛错。 +服务要求存在 `ctx.sessionProjections`,并注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本无法识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中的陈旧身份而非继续保留该身份——绝不抛错。 `registerContinuableSetup()` 允许可选包添加子级作用域能力,而无需让继续执行管理器知道这些能力的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent 会话仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛出异常时,不会据此作出分类判断,因为缓存只是派生数据;静默落到该权威重折。分类结果完全以投影折叠为准;列表操作本身不解析描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态,并把仅存于存储的状态映射为可恢复而非终态的 `ready`(`running`/`idle`/`ready`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent 会话仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 通过 `stateOf()` 读取;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛出异常时,不会据此作出分类判断,因为缓存只是派生数据;静默落到该权威重折。分类结果完全以投影折叠为准;列表操作本身不解析描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态,并把仅存于存储的状态映射为可恢复而非终态的 `ready`(`running`/`idle`/`ready`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。 diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 45b96a2311..7e6d7840d1 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -169,6 +169,8 @@ declare module '@deepseek-ai/cordis' { /** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */ export class SubagentRuntime extends Service { + static inject = ['sessionProjections'] + private providers = new Map() private continuations: SubagentContinuationManager | undefined /** Deployment contributions composed into unpublished continuable children. */ @@ -194,10 +196,8 @@ export class SubagentRuntime extends Service { if (this.continuations === manager) this.continuations = undefined }, 'subagents.continuationBinding()') }) - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) - projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition) - }) + ctx.sessionProjections.register(subagentTimingProjectionDefinition) + ctx.sessionProjections.register(subagentIdentityProjectionDefinition) } /** @@ -334,7 +334,7 @@ export class SubagentRuntime extends Service { * row when the optional cache serves an own-suffix identity (its `seq` * gate proves the value postdates the fork seed, where a child's own * descriptor is immutable once appended), else one persistence inspection - * folded through the registry. The + * folded through the same unit. The * projection fold is the single classification authority; per-child * diagnostics relay a fold that served no identity or a failed inspection, * never a list-time descriptor parse. Absent persistence, enumeration is @@ -349,8 +349,8 @@ export class SubagentRuntime extends Service { * @param signal - caller-owned cancellation forwarded to persistence reads * and observed around every read await. * @returns children and per-child diagnostics ordered by `createdAt`, then id. - * @throws {@link SubagentError} when the projection registry or the session - * store is not mounted, or the caller cancels the listing. + * @throws {@link SubagentError} when the session store is not mounted, or + * the caller cancels the listing. */ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise { return listSubagentChildren(this.ctx, parentSessionId, signal) diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 95c3be8520..7b45b405bb 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -5,7 +5,7 @@ * mode/label is the registered `subagent` projection unit's value, resolved * down a three-rung ladder: the registry's watermark cache for a live child, * a durable projection-cache row when it serves an own-suffix identity (the - * seq gate), and one persistence inspection folded through the registry + * seq gate), and one persistence inspection folded through the same unit * otherwise, validated against the enumerated lifecycle. The projection fold * is the single classification authority — this module parses no descriptor * itself. Absent persistence, enumeration is live-only: a cold child is @@ -22,6 +22,7 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache' import { SubagentError } from './error.ts' +import { subagentIdentityProjectionDefinition } from './projection.ts' import type { SubagentIdentityProjection } from './projection-types.ts' /** @@ -77,11 +78,9 @@ export type SubagentListEntry = * Why the candidate has no `child` row: `corrupt` for a settled candidate * whose projection fold served no identity (a missing, malformed, or * unrecognized-version descriptor — deliberately undistinguished), and - * for any candidate whose log makes a registered unit's fold or schema - * throw (deterministic data damage, contained per child); `unavailable` - * when the candidate's persistence inspection failed (retried on the - * next listing). `unsupported` is never produced; it remains in the - * union for consumers that route on it. + * `unavailable` when the candidate's persistence inspection failed + * (retried on the next listing). `unsupported` is never produced; it + * remains in the union for consumers that route on it. */ readonly reason: 'corrupt' | 'unsupported' | 'unavailable' } @@ -117,19 +116,19 @@ interface PositionedCandidate { /** * Enumerate one parent's origin-classified direct children from the * live-preferred merge of `ctx.sessions` and optional session persistence, - * serving each identity from the `subagent` projection unit: the registry's - * watermark snapshot for a live child; for a cold one, a durable + * serving each identity from the `subagent` projection unit: `stateOf()` for + * a live child; for a cold one, a durable * projection-cache row when it serves an own-suffix identity (the seq gate), * else one bounded-concurrency persistence inspection folded through the - * registry. + * same unit. * @see SubagentRuntime.listChildren for the public cancellation and failure contract. * @param ctx - context carrying the session store, the projection registry, * optional persistence, and the optional projection cache. * @param parentSessionId - parent session whose direct children are listed. * @param signal - caller-owned cancellation observed around every persistence read. * @returns children and per-child diagnostics ordered by `createdAt`, then id. - * @throws {@link SubagentError} when the projection registry or the session - * store is not mounted, or the caller cancels the listing. + * @throws {@link SubagentError} when the session store is not mounted or the + * caller cancels the listing. */ export async function listChildren( ctx: Context, @@ -185,16 +184,7 @@ async function prepareListing( ctx: Context, signal: AbortSignal | undefined, ): Promise { - const projections = ctx.get('sessionProjections') - // Checked before any read, even with zero candidates: mode/label are the - // row's strong contract, so a missing fold capability is a deterministic - // deployment configuration error, never an empty success. - if (projections === undefined) { - throw new SubagentError( - 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)', - 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', - ) - } + const projections = ctx.sessionProjections // Strict global read, never the `ctx.sessions` property proxy: the proxy is // caller-scope bound, so a consumer plugin without its own `sessions` // injection (the model-facing tool, the API proxy) would throw on access. @@ -257,17 +247,7 @@ async function resolveCandidateRows( // The registry's watermark cache serves the live value with zero log // reads; a live child without an identity yet is the creation window // before the establishing provider appends its descriptor. - let identity: SubagentIdentityProjection | null | undefined - try { - identity = projections.snapshot(candidate.live).values.subagent - } catch { - // The snapshot folds EVERY registered unit over this child's log, so - // any unit's fold or schema can reject damaged payloads. That is - // deterministic data damage in this one child; it degrades to one - // corrupt diagnostic instead of failing the whole listing. - rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' } - return - } + const identity = projections.stateOf(candidate.live, 'subagent') // The unit's serializable no-value sentinel is `null`; `undefined` can // only mean the key was dropped at a JSON boundary. Both are no value. if (identity === undefined || identity === null) return @@ -283,7 +263,7 @@ async function resolveCandidateRows( async () => { for (let job = queue.shift(); job !== undefined; job = queue.shift()) { rows[job.index] = await resolveColdIdentity( - persistence, projections, cache, job.header, + persistence, cache, job.header, subagentParents.has(job.header.id), signal, ) } @@ -338,16 +318,13 @@ function compareCorpusRecords(a: CorpusRecord, b: CorpusRecord): number { /** * Resolve one cold candidate down the remaining ladder: a durable * projection-cache row when it serves an own-suffix identity (the seq gate), - * otherwise one persistence inspection folded through the projection - * registry (the same detached recipe the API proxy uses for detached session - * projections). A failed inspection is one transient `unavailable` row - * retried on the next listing; an inspection naming another lifecycle, and a - * settled log the fold cannot identify — or that makes any registered unit - * throw — are final, so they report `corrupt`. + * otherwise one persistence inspection folded through the same subagent unit. + * A failed inspection is one transient `unavailable` row retried on the next + * listing; an inspection naming another lifecycle or a settled log the fold + * cannot identify reports `corrupt`. */ async function resolveColdIdentity( persistence: SessionPersistence, - projections: SessionProjectionRegistry, cache: SessionProjectionCache | undefined, header: SessionHeader, hasChildren: boolean, @@ -393,16 +370,11 @@ async function resolveColdIdentity( if (!sameLifecycle(inspected.meta, header)) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } - let identity: SubagentIdentityProjection | null | undefined - try { - identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent - } catch { - // The restore folds EVERY registered unit over this child's log, so any - // unit's fold or schema can reject damaged payloads — deterministic data - // damage in this one child, contained as its own corrupt diagnostic. - return { kind: 'diagnostic', id: childId, reason: 'corrupt' } + let identity: SubagentIdentityProjection | null = subagentIdentityProjectionDefinition.init() + for (const event of inspected.events) { + identity = subagentIdentityProjectionDefinition.apply(identity, event) } - if (identity === undefined || identity === null) { + if (identity === null) { return { kind: 'diagnostic', id: childId, reason: 'corrupt' } } return childRow(childId, identity, 'inactive', hasChildren) diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index 046c32e91d..b07ec8e8a2 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -47,9 +47,7 @@ export type SubagentIdentityProjection = } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionMap { - /** Active-turn duration for a descriptor-backed subagent session. */ - subagentTiming: SubagentTimingProjection + interface SessionProjectionStateMap { /** * Identity of a descriptor-backed subagent session. `null` ⟺ no valid * descriptor (missing, malformed, or unrecognized-version — deliberately @@ -61,4 +59,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' { */ subagent: SubagentIdentityProjection | null } + interface SessionProjectionMap { + /** Active-turn duration for a descriptor-backed subagent session. */ + subagentTiming: SubagentTimingProjection + } } diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index 288949ac26..d0185144d3 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -12,42 +12,35 @@ import { foldSubagentDescriptor } from './descriptor.ts' import type { SubagentDescriptorData } from './descriptor.ts' import type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' -/** Fold state for a subagent's latest timing snapshot. */ -export interface TimingState { - /** Milliseconds accumulated across completed post-descriptor turns. */ - settledMs: number - /** Current open interval kept paired inside the fold. */ - active?: { since: number; through: number } | undefined - /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */ - pendingTurnStart?: number | undefined - /** Whether the fold has crossed a descriptor in this logical log. */ - descriptorSeen: boolean -} +const settledMsField = z.number().int().nonnegative() + +const activeField = z.object({ + since: z.number().int().nonnegative(), + through: z.number().int().nonnegative(), +}).strict().optional() // Zod's optional output includes explicit `undefined`; with // exactOptionalPropertyTypes the public interface permits omission only. const projectionSchema = z.object({ - settledMs: z.number().int().nonnegative(), - active: z.object({ - since: z.number().int().nonnegative(), - through: z.number().int().nonnegative(), - }).strict().optional(), + settledMs: settledMsField, + active: activeField, }).strict() as unknown as z.ZodType -const timingStateSchema: z.ZodType = z.object({ - settledMs: z.number().int().nonnegative(), - active: z.object({ - since: z.number().int().nonnegative(), - through: z.number().int().nonnegative(), - }).strict().optional(), +const timingStateSchema = z.object({ + settledMs: settledMsField, + active: activeField, + /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */ pendingTurnStart: z.number().int().nonnegative().optional(), + /** Whether the fold has crossed a descriptor in this logical log. */ descriptorSeen: z.boolean(), }).strict() +/** Fold state for descriptor-relative active-turn duration. */ +export type TimingState = z.infer + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { subagentTiming: TimingState - subagent: IdentityState } } @@ -61,6 +54,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { */ export const subagentTimingProjectionDefinition = { key: 'subagentTiming', + stateVersion: 2, stateSchema: timingStateSchema, init: () => ({ descriptorSeen: false, settledMs: 0 }), apply: (state, event) => { @@ -102,14 +96,8 @@ export const subagentTimingProjectionDefinition = { ...(state.active === undefined ? {} : { active: state.active }), }), }, - stateVersion: 2, } satisfies ProjectionDefinition<'subagentTiming', TimingState> -interface IdentityState { - /** Identity from the last valid descriptor; absent before one, and after an invalid one. */ - identity?: SubagentIdentityProjection | undefined -} - // The cast bridges only the optional-label arm: Zod's optional output // includes explicit `undefined`, which exactOptionalPropertyTypes excludes // from the public interface. The no-value state itself is the serializable @@ -130,10 +118,6 @@ const identityValueSchema = z.discriminatedUnion('mode', [ const identitySchema = identityValueSchema.nullable() -const identityStateSchema: z.ZodType = z.object({ - identity: identityValueSchema.optional(), -}).strict() - /** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined { let descriptor: SubagentDescriptorData | undefined @@ -167,15 +151,13 @@ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | u */ export const subagentIdentityProjectionDefinition = { key: 'subagent', - stateSchema: identityStateSchema, - init: () => ({}), + stateVersion: 3, + stateSchema: identitySchema, + persist: true, + init: () => null, apply: (state, event) => { if (event.type !== 'subagent/descriptor') return state const identity = descriptorIdentity(event) - return identity === undefined ? {} : { identity } + return identity === undefined ? null : identity }, - wire: { viewSchema: identitySchema, view: state => state.identity ?? null }, - // Bumped when the identity gained its `seq` field: an older checkpoint row - // would replay into a value the schema rejects, so it must refold instead. - stateVersion: 2, -} satisfies ProjectionDefinition<'subagent', IdentityState> +} satisfies ProjectionDefinition<'subagent', SubagentIdentityProjection | null> diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 3f2c0b1e31..eb69b47119 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -15,13 +15,14 @@ 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 { createUserMessage } from '@deepseek-ai/dsh-llm' -import SandboxPolicyService, { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' -import { SessionId } from '@deepseek-ai/dsh-session' +import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' -import ApprovalService, { effectiveApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService from '@deepseek-ai/dsh-user-approval' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentRuntime from '../src/index.ts' @@ -39,6 +40,7 @@ async function setup(script: Script) { const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) @@ -73,6 +75,14 @@ function policyEvents(events: readonly SessionEvent[]) { return events.filter(event => event.type === 'sandbox/mode' || event.type === 'approval/policy') } +function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { + return ctx.sessionProjections.snapshot(Session.create(id, events)).values.sandboxMode +} + +function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { + return ctx.sessionProjections.snapshot(Session.create(id, events)).values.approvalPolicy +} + describe('continuable policy inheritance', () => { it('seeds the parent sandbox override and pins approval to never', { timeout: 20_000 }, async () => { const { ctx, parent } = await setup([textResponse('child done')]) @@ -98,8 +108,8 @@ describe('continuable policy inheritance', () => { { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) // Durable: a reload folds the same effective policy. - expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access') - expect(effectiveApprovalPolicy(loaded.events)).toBe('never') + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('danger-full-access') + expect(foldedApprovalPolicy(ctx, started.childId, loaded.events)).toBe('never') expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() const runtimeContext = loaded.events.find( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' @@ -125,7 +135,7 @@ describe('continuable policy inheritance', () => { await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access') - expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only') }) it('leaves an unswitched sandbox on the deployment default while still pinning approval', { timeout: 20_000 }, async () => { @@ -138,7 +148,7 @@ describe('continuable policy inheritance', () => { expect(policyEvents(loaded.events)).toMatchObject([ { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) - expect(effectiveSandboxMode(loaded.events)).toBeUndefined() + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBeNull() }) it('pins approval after the fork prefix of an unswitched fork child', { timeout: 20_000 }, async () => { @@ -157,7 +167,7 @@ describe('continuable policy inheritance', () => { expect(policyEvents(loaded.events)).toMatchObject([ { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, ]) - expect(effectiveSandboxMode(loaded.events)).toBeUndefined() + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBeNull() }) it('lets a later child-side switch win over the delegation snapshot', { timeout: 20_000 }, async () => { @@ -177,7 +187,7 @@ describe('continuable policy inheritance', () => { await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only') }) it('cold-resumes on the persisted snapshot without re-capturing the parent', { timeout: 20_000 }, async () => { @@ -199,7 +209,7 @@ describe('continuable policy inheritance', () => { expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ { data: { mode: 'read-only', source: 'delegation' } }, ]) - expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only') // The approval pin is seeded once at creation, never re-appended on resume. expect(loaded.events.filter(event => event.type === 'approval/policy')).toMatchObject([ { data: { policy: 'never', source: 'delegation' } }, @@ -226,6 +236,6 @@ describe('continuable policy inheritance', () => { { data: { mode: 'workspace-write' } }, { data: { mode: 'read-only', source: 'delegation' } }, ]) - expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only') }) }) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index d7ef69a593..7d85b5a595 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -9,6 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -60,6 +61,9 @@ afterEach(() => { async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + // The registry is a required injection of AgentLoop and SubagentRuntime + // (both register projection units on activation). + await ctx.plugin(SessionProjectionRegistry) let disposePersistence: (() => Promise) | undefined let root: string | undefined if (options.persistence !== false) { @@ -404,6 +408,7 @@ describe('SubagentRuntime.startContinuable', () => { const fresh = new Context() await mountAgentLoopTestDependencies(fresh) + await fresh.plugin(SessionProjectionRegistry) await fresh.plugin(JsonlSessionPersistence, { root: root! }) await fresh.plugin(AgentLoop, { agents: [] }) await fresh.plugin(SubagentRuntime) @@ -2431,6 +2436,7 @@ describe('continuable errors', () => { const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) diff --git a/packages/subagent/subagent/tests/invariant.spec.ts b/packages/subagent/subagent/tests/invariant.spec.ts index 91200abf8d..597c22223c 100644 --- a/packages/subagent/subagent/tests/invariant.spec.ts +++ b/packages/subagent/subagent/tests/invariant.spec.ts @@ -10,9 +10,13 @@ import type { } from '@deepseek-ai/dsh-subagent' import * as SubagentInvariant from '@deepseek-ai/dsh-subagent/invariant' import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' async function setup(): Promise { const ctx = new Context() + // The registry is a required injection of SubagentRuntime (its projection + // units register in the constructor). + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(InvariantRegistry) await ctx.plugin(SubagentInvariant) diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index e9a5633189..018b11f16e 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -2,16 +2,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { z } from 'zod' import { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' +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 SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' @@ -55,8 +54,10 @@ async function setup( await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) - return { ctx, parent } + const parent = ctx.get('agentLoop') === undefined + ? undefined + : ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent: parent as Agent } } const testSignal = new AbortController().signal @@ -117,40 +118,6 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION) return { version, mode: 'continuable' as const, provider: 'spawn', label } } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - subagentListHostileProbe: { poisoned?: boolean | undefined } - } - interface SessionProjectionMap { - /** Test-only hostile probe proving per-child isolation of foreign unit failures. */ - subagentListHostileProbe: null - } -} - -/** - * A foreign registered unit that rejects one specific child's log at view - * time: `apply` never throws (the eager drive passes every committed event - * through it), while the poisoned state detonates only when a listing read - * folds or serves this child through the registry. - */ -const hostileProjectionDefinition = { - key: 'subagentListHostileProbe', - stateSchema: z.object({ poisoned: z.boolean().optional() }), - init: () => ({}), - apply: (state, event) => - event.type === 'subagent/descriptor' && (event.data as { label?: string }).label === 'poison me' - ? { poisoned: true } - : state, - wire: { - viewSchema: z.null(), - view: (state) => { - if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log') - return null - }, - }, - stateVersion: 1, -} satisfies ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean | undefined }> - describe('SubagentRuntime.listChildren', () => { it('lists live children without persistence, query services, or the continuation runtime', async () => { const ctx = new Context() @@ -180,11 +147,10 @@ describe('SubagentRuntime.listChildren', () => { ]) }) - it('fails loud when the projection registry is not mounted, even with no children', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow( - expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error, - ) + it('never activates without the projection registry (mandatory seam)', async () => { + const { ctx } = await setup([], { sessionProjections: false }) + expect(ctx.get('subagents')).toBeUndefined() + expect(ctx.get('agentLoop')).toBeUndefined() }) it('fails loud when the session store is not mounted', async () => { @@ -595,47 +561,6 @@ describe('SubagentRuntime.listChildren', () => { ]) }) - it('contains a foreign unit failure during a cold fold to that child as corrupt', async () => { - const { ctx, parent } = await setup([textResponse('done')]) - ctx.sessionProjections.register(hostileProjectionDefinition) - const healthy = await startChild(ctx, parent, 'healthy sibling') - const poisoned = await authorChild(ctx, '00000000-0000-4000-8000-00000000d00d', { - parentSession: parent.id, - origin: 'subagent', - }, childEvents(descriptorPayload('poison me'))) - // The subagent unit itself folds this child cleanly; the FOREIGN unit's - // view throws, and that damage stays contained to the one child. - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toContainEqual({ kind: 'diagnostic', id: poisoned, reason: 'corrupt' }) - expect(entries).toContainEqual({ - kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', - activity: 'inactive', hasChildren: false, - }) - }) - - it('contains a foreign unit failure during a live snapshot to that child as corrupt', async () => { - const { ctx, parent } = await setup([]) - ctx.sessionProjections.register(hostileProjectionDefinition) - const poisonedId = SessionId('live-poisoned-child') - const poisoned = ctx.sessions.create(poisonedId, { - meta: { parentSession: parent.id, origin: 'subagent' }, - }) - poisoned.append('turn/start', { turn: 1 }) - poisoned.append('subagent/descriptor', descriptorPayload('poison me')) - const healthyId = SessionId('live-healthy-child') - const healthy = ctx.sessions.create(healthyId, { - meta: { parentSession: parent.id, origin: 'subagent' }, - }) - healthy.append('turn/start', { turn: 1 }) - healthy.append('subagent/descriptor', descriptorPayload('live healthy')) - const entries = await ctx.subagents.listChildren(parent.id) - expect(entries).toContainEqual({ kind: 'diagnostic', id: poisonedId, reason: 'corrupt' }) - expect(entries).toContainEqual({ - kind: 'child', id: healthyId, label: 'live healthy', mode: 'continuable', - activity: 'running', hasChildren: false, - }) - }) - it('fails the whole enumeration when the persisted listing itself fails', async () => { const { ctx, parent } = await setup([textResponse('done')]) await startChild(ctx, parent, 'never listed') @@ -775,8 +700,9 @@ describe('SubagentRuntime.listChildren', () => { // The child's turn/end and disposal are the cache's mandatory checkpoint // points; both writes are fail-soft asynchronous, so wait for the row. const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId) - await vi.waitFor(() => { - expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined() + await vi.waitFor(async () => { + const cut = ctx.sessionProjectionCache.cachedSnapshot(header!) + expect(cut?.values.subagent).toBeDefined() }, { timeout: 5_000 }) const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ @@ -972,10 +898,14 @@ describe('SubagentRuntime.listChildren', () => { }) it('SubagentError from listChildren is typed with its stable code', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error) + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentRuntime) + const caught: unknown = await ctx.subagents + .listChildren(SessionId('no-store-parent')) + .catch((error: unknown) => error) expect(caught).toBeInstanceOf(SubagentError) - expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') + expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE') }) }) @@ -1211,10 +1141,8 @@ describe('SubagentRuntime.listDescendants', () => { expect(list).not.toHaveBeenCalled() }) - it('fails loud when the projection registry is not mounted', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - await expect(ctx.subagents.listDescendants(parent.id)).rejects.toThrow( - expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error, - ) + it('does not activate when the projection registry is not mounted', async () => { + const { ctx } = await setup([], { sessionProjections: false }) + expect(ctx.subagents).toBeUndefined() }) }) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 05e9785611..c998ca2c02 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -19,6 +19,7 @@ import SubagentRuntime, { type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' function fakeParent(id = 'parent-1'): Agent { return { id: SessionId(id) } as unknown as Agent @@ -64,6 +65,9 @@ class StubProvider implements SubagentProvider { async function service(): Promise<{ ctx: Context; subagents: SubagentRuntime }> { const ctx = new Context() + // The registry is a required injection of SubagentRuntime (its projection + // units register in the constructor). + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) return { ctx, subagents: ctx.subagents } } diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 4eb8008c3b..4c3ba93d13 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -247,6 +247,7 @@ describe('dsh-tool-subagent-control/list-agents', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true) diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 0636254841..9abe4a5a88 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -207,6 +207,7 @@ describe('dsh-tool-subagent-control', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true) diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index dbadc60e88..9eb9252af1 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 28d6068757..90fec316e0 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -13,6 +13,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as control from '@deepseek-ai/dsh-tool-subagent-control' import { textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -68,6 +69,7 @@ async function setup(options: { load?: boolean; config?: tool.Config } = {}) { const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-report-')) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) const fiber = options.load === false diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 8de6a04739..2549e46501 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts index 67ebfb1388..d54fa53c3b 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { type Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { SessionId } from '@deepseek-ai/dsh-session' import * as scripted from './scripted-provider.ts' @@ -21,6 +22,7 @@ function baseRequest(over: Partial = {}): SubagentStartReq async function mount(config: Partial = {}): Promise { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await scripted.mountScriptedProvider(ctx, { name: 'mock', ...config }) return ctx @@ -89,6 +91,7 @@ describe('scripted subagent provider fixture', () => { it('unregisters with its owning fixture fiber', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await scripted.mountScriptedProvider(ctx, { name: 'mock' }) expect(ctx.subagents.list()).toEqual(['mock']) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 1ee5e40228..f731622d2b 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -13,6 +13,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -41,6 +42,7 @@ async function setup(toolConfig: tool.Config, mockConfig: Partial = const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) await ctx.plugin(tool, toolConfig) @@ -208,6 +210,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await mock.mountScriptedProvider(ctx, { name: 'spawn', reply: 'from spawn' }) await mock.mountScriptedProvider(ctx, { name: 'acp', reply: 'from acp' }) @@ -229,6 +232,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'weird', @@ -255,6 +259,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture', @@ -285,6 +290,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'bare', @@ -320,6 +326,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) // Tool first: no provider yet — the tool must be absent, not broken. // Direct apply (schema bypass): also covers the waiting-note's default @@ -337,6 +344,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) tool.apply(ctx, { provider: 'later-continuable', @@ -353,6 +361,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const backend = await mock.mountScriptedProvider(ctx, { name: 'mock' }) // fresh conversation (descriptor: false) await ctx.plugin(tool, { provider: 'mock' }) @@ -372,6 +381,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) // Arm 1: a mounted tool and its prompt section die with the plugin fiber; @@ -408,6 +418,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await mock.mountScriptedProvider(ctx, { name: 'mock' }) await ctx.plugin(tool, { provider: 'mock' }) @@ -444,6 +455,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -467,6 +479,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -491,6 +504,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -519,6 +533,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -546,6 +561,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -585,6 +601,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spy', @@ -649,6 +666,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture2', @@ -706,6 +724,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture3', @@ -736,6 +755,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture4', @@ -761,6 +781,7 @@ describe('dsh-tool-subagent', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'p', @@ -1088,6 +1109,7 @@ describe('dsh-tool-subagent continuable background mode', () => { roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(LocalJobRegistry) @@ -1292,6 +1314,7 @@ describe('depth budget configuration', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'capture', @@ -1330,6 +1353,7 @@ describe('depth budget configuration', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'no-depth', @@ -1346,6 +1370,7 @@ describe('depth budget configuration', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'external', diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index b1b8ae6a59..b6042e122b 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -39,7 +39,8 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index d0b2d6a564..a9a17950b4 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -11,7 +11,8 @@ import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal' import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh-terminal' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' -import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-session-projection' import { type Config, type ResolvedConfig, validateConfig } from './config.ts' import { LocalPtySession } from './session.ts' import { CONTROLLED_PROMPT } from './sanitize.ts' @@ -21,12 +22,13 @@ export type { Config as TerminalLocalConfig } from './config.ts' /** Cordis plugin name. */ export const name = 'terminal-bash' -/** Required services: PTY registry, shared confinement policy, and process substrate. */ -export const inject = ['terminals', 'sandboxPolicy', 'subprocess'] +/** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */ +export const inject = ['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess'] interface SandboxModeFenceState { pty: Context['terminals'] sandboxPolicy: Context['sandboxPolicy'] + sessionProjections: Context['sessionProjections'] } const sandboxModeFences = new WeakMap() @@ -36,15 +38,21 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { if (existing !== undefined) { existing.pty = ctx.terminals existing.sandboxPolicy = ctx.sandboxPolicy + existing.sessionProjections = ctx.sessionProjections return } - const state: SandboxModeFenceState = { pty: ctx.terminals, sandboxPolicy: ctx.sandboxPolicy } + const state: SandboxModeFenceState = { + pty: ctx.terminals, + sandboxPolicy: ctx.sandboxPolicy, + sessionProjections: ctx.sessionProjections, + } sandboxModeFences.set(owner, state) owner.ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (session !== owner.session || event.type !== 'sandbox/mode') return - const currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode + const folded = state.sessionProjections.stateOf(session, 'sandboxMode') ?? null + const currentMode = folded ?? state.sandboxPolicy.defaultMode if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return throw new Error( `cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`, diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 2a4d7848df..015b6562ed 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -7,6 +7,7 @@ import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TerminalSessionService, { TerminalBackendCleanupError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash' import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash' @@ -99,7 +100,7 @@ function stubLocalSession(initialize: () => Promise = () => Promise.resolv } function registerStubLocalBackend(ctx: Context, createSession: () => LocalPtySession) { - return ctx.inject(['terminals', 'sandbox', 'sandboxPolicy', 'subprocess'], (providerCtx) => { + return ctx.inject(['terminals', 'sandbox', 'sandboxPolicy', 'sessionProjections', 'subprocess'], (providerCtx) => { providerCtx.terminals.registerBackend(new BashTerminalBackend( providerCtx, { ...config(), backendType: 'stub' }, @@ -113,6 +114,7 @@ describe('BashTerminalBackend startup rollback', () => { it('rejects pre-aborted setup and empty sandbox argv', async () => { const ctx = new Context() await ctx.plugin(EmptySandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/tmp' }) const backend = new BashTerminalBackend(ctx, config(), async () => terminalHandle()) const controller = new AbortController() @@ -124,6 +126,7 @@ describe('BashTerminalBackend startup rollback', () => { it('closes failed startup and aggregates cleanup failure', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) const spawnTerminal = async (): Promise => terminalHandle() @@ -149,6 +152,7 @@ describe('BashTerminalBackend startup rollback', () => { it('starts startup rollback when cancellation wins a stalled initialization', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) const initialization = Promise.withResolvers() const initializationStarted = Promise.withResolvers() @@ -176,6 +180,7 @@ describe('BashTerminalBackend startup rollback', () => { it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => { const ctx = new Context() await ctx.plugin(RecordingSandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' }) const terminal = terminalHandle() let spawned: SubprocessTerminalSpawnSpec | undefined @@ -223,6 +228,7 @@ describe('BashTerminalBackend startup rollback', () => { it('resolves session mode and root together before wrapping the shell', async () => { const ctx = new Context() await ctx.plugin(RecordingSandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/deployment-fallback' }) const terminal = terminalHandle() let spawned: SubprocessTerminalSpawnSpec | undefined @@ -254,6 +260,7 @@ describe('BashTerminalBackend startup rollback', () => { it('rejects a confined spawn without a sandbox provider', async () => { const confinedCtx = new Context() + await confinedCtx.plugin(SessionProjectionRegistry) await confinedCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' }) const confined = new BashTerminalBackend( confinedCtx, @@ -269,6 +276,7 @@ describe('BashTerminalBackend startup rollback', () => { it('forwards terminal allocation cancellation directly', async () => { const ctx = new Context() await ctx.plugin(EmptySandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) const publishedController = new AbortController() @@ -313,6 +321,7 @@ describe('BashTerminalBackend startup rollback', () => { it('composes the default local session around a spawned terminal', async () => { const ctx = new Context() await ctx.plugin(EmptySandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) const output = new PassThrough() const outcome = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>() @@ -346,7 +355,7 @@ describe('terminal-bash plugin shape', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(ptyLocal) as Record expect(unwrapped.name).toBe('terminal-bash') - expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'subprocess']) + expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess']) expect(unwrapped.Config).toBeDefined() }) @@ -354,6 +363,7 @@ describe('terminal-bash plugin shape', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) const fiber = await ctx.plugin(ptyLocal, config()) @@ -368,6 +378,7 @@ describe('terminal-bash plugin shape', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(EmptySandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) await ctx.plugin(ptyLocal, config()) @@ -385,6 +396,7 @@ describe('terminal-bash plugin shape', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(RecordingSandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) @@ -434,6 +446,7 @@ describe('terminal-bash plugin shape', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(RecordingSandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' }) await ctx.plugin(StubSubprocessRuntime) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index c7af8668a3..e4f5712656 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -11,6 +11,7 @@ import type { TerminalSendOperation } from '@deepseek-ai/dsh-terminal' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash' @@ -57,6 +58,7 @@ async function harness( await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) await ctx.plugin(PassthroughSandbox) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root }) await ctx.plugin(LocalSubprocessRuntime) const fiber = await ctx.plugin(ptyLocal, { diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index 9fe3475e74..d43bf9737a 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -63,6 +63,7 @@ "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts index ada46ef78f..279198a8a1 100644 --- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts +++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts @@ -16,6 +16,7 @@ import TerminalSessionService from '@deepseek-ai/dsh-terminal' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash' import * as ToolPty from '@deepseek-ai/dsh-tool-terminal' @@ -69,6 +70,7 @@ suite('terminal real Loader composition through cordis.yml', () => { "- name: '@deepseek-ai/dsh-tools'", "- name: '@deepseek-ai/dsh-terminal'", "- name: '@deepseek-ai/dsh-test-sandbox'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-sandbox-policy'", ' config:', ' mode: danger-full-access', @@ -96,6 +98,7 @@ suite('terminal real Loader composition through cordis.yml', () => { ['@deepseek-ai/dsh-tools', ToolRuntime], ['@deepseek-ai/dsh-terminal', TerminalSessionService], ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime], ['@deepseek-ai/dsh-terminal-bash', TerminalLocal], diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 469cb71023..e386d0cfcb 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.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 packages/todo/tool-todo/README.md -README.md: 4848758dd8f2049221901744e5d09cef2dd31591 -README.zh.md: f3e9e50e1c548dc8c0d01b0bae5c91dd813b5899 +README.md: dfd9b59c19a65f128068514ddef2a4b078ff99cb +README.zh.md: 055ac3f20a599a75d302435459e5c8040dade30b diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 4848758dd8..dfd9b59c19 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -30,7 +30,7 @@ The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `todos` projection unit under an injected child: `init` = `null` (no write yet), `apply` = take the whole list from each `todo/write` and clear to `null` on each `turn/start` (standing plan; `turn/end` keeps the finished checklist; every other event returns the same state reference), `view` = identity, `stateVersion` = 2. The key merges into `SessionProjectionMap` here (via the Service Definition package's `/types` outlet); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. Lifetime rationale: [todo plan clears on next turn](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md). +This package requires `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)) and registers the `todos` projection unit: `init` = `null` (no write yet), `apply` = take the whole list from each `todo/write` and clear to `null` on each `turn/start` (standing plan; `turn/end` keeps the finished checklist; every other event returns the same state reference), client view = identity, `stateVersion` = 2. The key merges into both `SessionProjectionStateMap` and `SessionProjectionMap`; carriers serve the client value on the history tail page and the `session/projection` push frame. Lifetime rationale: [todo plan clears on next turn](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md). ## Export shape diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index f3e9e50e1c..055ac3f20a 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -30,7 +30,7 @@ ## 会话投影 -当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包在一个注入的子插件中注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(当前有效计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。该键在本包中合并进 `SessionProjectionMap`(经 Service Definition 包的 `/types` 出口);框架驱动该单元,载体通过历史尾页与 `session/projection` 推送帧提供该值。未挂载注册表的组合不受影响。生命周期理由见 [在下一轮次清空 todo 计划](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。 +本包要求组合提供 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)),并注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(当前有效计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、客户端视图 = 恒等、`stateVersion` = 2。该 key 同时合并进 `SessionProjectionStateMap` 与 `SessionProjectionMap`;载体通过历史尾页与 `session/projection` 推送帧提供客户端值。生命周期理由见 [在下一轮次清空 todo 计划](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。 ## 导出形状 diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 9c0a8e45a7..ef82b2f19d 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -11,16 +11,15 @@ import { z as zod } from 'zod' import type { ZodType } from 'zod' import { defineTool } from '@deepseek-ai/dsh-tools' import type { TodoItem } from '@deepseek-ai/dsh-session' -// Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' // The `todos` projection-key declaration lives in src/types.ts (its one home); // this re-export projects the type face onto the package root AND keeps the // module edge in the emitted index.d.ts, so aggregate programs consuming the -// declarations still receive the SessionProjectionMap merge. +// declarations still receive the SessionProjectionStateMap merge. export type * from './types.ts' export const name = 'tool-todo' -export const inject = ['tools'] +export const inject = ['tools', 'sessionProjections'] /** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */ const STATUSES = ['pending', 'in_progress', 'completed'] as const @@ -120,31 +119,24 @@ const todosProjectionSchema: ZodType = zod.union([ ]) /** - * Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed, - * the `todos` unit. - * @param ctx - registrant context carrying the tool registry. + * Register the `todo_write` tool and `todos` projection. + * @param ctx - registrant context carrying the tool and projection registries. * @param config - deployment's explicit todo policy. */ export function apply(ctx: Context, config: Config): void { const allowParallel = config.allowParallelInProgress - // The unit child activates only when a projection registry is composed - // (headless assemblies without the seam stay unaffected). Standing-plan fold: - // latest whole todo/write list, cleared by the next turn/start (turn/end keeps - // the finished checklist visible); null before the first write or after a - // later turn begins; every other event returns the same state reference. - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register<'todos', TodoItem[] | null>({ - key: 'todos', - stateSchema: todosProjectionSchema, - init: () => null, - apply: (state, event) => { - if (event.type === 'todo/write') return event.data.todos - if (event.type === 'turn/start') return null - return state - }, - wire: { viewSchema: todosProjectionSchema, view: state => state }, - stateVersion: 2, - }) + ctx.sessionProjections.register({ + key: 'todos', + stateVersion: 2, + stateSchema: todosProjectionSchema, + init: () => null, + apply: (state, event) => { + if (event.type === 'todo/write') return event.data.todos + if (event.type === 'turn/start') return null + return state + }, + wire: { viewSchema: todosProjectionSchema, view: state => state }, + }) ctx.tools.register(defineTool({ name: 'todo_write', diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 264731bdbc..0692772ea9 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -5,6 +5,7 @@ import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' 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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -17,6 +18,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 945b2e84e5..e68ee90b3a 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -15,6 +15,7 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' let root: string | undefined @@ -58,6 +59,7 @@ async function boot(configLines: readonly string[]): Promise { "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-tool-todo'", ...configLines.length > 0 ? [' config:', ...configLines] : [], '', @@ -72,6 +74,7 @@ async function boot(configLines: readonly string[]): Promise { ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-tool-todo', ToolTodo], ]) ctx.loader.internal = { diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index dd959f2f9f..f6babf0157 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -4,6 +4,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { TodoItem } from '@deepseek-ai/dsh-session' import { type Agent } from '@deepseek-ai/dsh-agent' @@ -30,6 +31,7 @@ async function setup(allowParallelInProgress: boolean): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(tool, { allowParallelInProgress }) return ctx } @@ -213,6 +215,7 @@ describe('dsh-tool-todo', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(tool, { allowParallelInProgress: true }) expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(true) await fiber.dispose() @@ -223,13 +226,13 @@ describe('dsh-tool-todo', () => { // A default export would make Loader unwrap only apply and drop `inject`. expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-todo') - expect(tool.inject).toEqual(['tools']) + expect(tool.inject).toEqual(['tools', 'sessionProjections']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(tool) as Record expect(unwrapped).toBe(tool) expect(unwrapped.name).toBe('tool-todo') - expect(unwrapped.inject).toEqual(['tools']) + expect(unwrapped.inject).toEqual(['tools', 'sessionProjections']) expect(typeof unwrapped.apply).toBe('function') }) }) diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index a795d22ca8..b87c901ef6 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index e6de6c38ef..771a57afb0 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -5,6 +5,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -20,6 +21,7 @@ async function mountRalph(script: MockScript, config: toolRalph.Config) { const ctx = new Context() const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -57,6 +59,7 @@ 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(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts index 7c6f02e36c..69a699a245 100644 --- a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts +++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts @@ -4,6 +4,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -78,6 +79,7 @@ async function setup(options?: SetupOptions) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const provider = options?.provider === false ? undefined : options?.provider ?? new StubProvider() if (provider !== undefined) ctx.subagents.registerProvider(provider) diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 2bd0cd13a9..595459bc2e 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 938f9a5502..26847b7098 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -15,6 +15,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import WorkerThreadWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread' import * as toolWorkflow from '../src/index.ts' import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' const testToolSignal = new AbortController().signal @@ -420,6 +421,7 @@ describe('dsh-tool-workflow', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) ctx.subagents.registerProvider({ name: 'spawn', diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 8e2c094009..24fd17ae98 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/cordis": "workspace:^", - "tsx": "^4.19.2" + "tsx": "^4.19.2", + "@deepseek-ai/dsh-session-projection": "workspace:^" } } diff --git a/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts b/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts index 31998a22cf..f8714a5ac5 100644 --- a/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts @@ -22,10 +22,12 @@ describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built work try { await writeFile(driver, ` import { Context } from '@deepseek-ai/cordis' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import WorkerThreadWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread' const ctx = new Context() +await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) let selectedStarts = 0 ctx.subagents.registerProvider({ diff --git a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts index 33f5575693..686c342ad5 100644 --- a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts @@ -7,6 +7,7 @@ import InvariantRegistry 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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' @@ -35,6 +36,7 @@ async function setup(script: Script) { const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts index 8d33e373c7..19c7e79f69 100644 --- a/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts @@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import WorkerThreadWorkflowEngine from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' @@ -18,6 +19,7 @@ vi.setConfig({ testTimeout: 30_000 }) it('runs the default config through the source worker', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) const subagents = await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'spawn', diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts index b87d0eb62a..daf7516ca2 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts @@ -7,6 +7,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -32,6 +33,7 @@ async function harness(): Promise { const built = new Context() await built.plugin(LlmRuntime) await built.plugin(SessionStore) + await built.plugin(SessionProjectionRegistry) await built.plugin(SystemPrompt) await built.plugin(ToolRuntime) await built.plugin(AgentRegistry) diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts index 5130071362..9e90c400b4 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts @@ -13,6 +13,7 @@ import WorkerThreadWorkflowEngine, { type Config } from '../src/index.ts' import { workerSpawnEnv } from '../src/host.ts' import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' import { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' /** A minimal parent stand-in: the engine only threads it through to the provider. */ function fakeParent(): Agent { @@ -143,6 +144,7 @@ interface SetupOptions { async function setup(options?: SetupOptions) { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const provider = new StubProvider( 'stub', @@ -456,6 +458,7 @@ describe('dsh-workflow-worker-thread', () => { it('a child result REJECTION crosses back as a fatal AGENT_RESULT error (a broken provider is not a failed child)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'rejecting', @@ -515,6 +518,7 @@ describe('dsh-workflow-worker-thread', () => { it('a child whose dispose() throws synchronously cannot wedge the script (the host acks anyway)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'bad-dispose', @@ -537,6 +541,7 @@ describe('dsh-workflow-worker-thread', () => { it('a child dispose() rejecting an UNRENDERABLE value still acks — the containment warn is total', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const provider: SubagentProvider = { name: 'coercion-trap-dispose', @@ -887,6 +892,7 @@ describe('dsh-workflow-worker-thread', () => { it('the settle-reap fires the request signal too: a provider honoring ONLY the signal winds its stray down promptly', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const aborted: string[] = [] const provider: SubagentProvider = { @@ -1182,6 +1188,7 @@ describe('dsh-workflow-worker-thread', () => { it('refuses and disposes a provider run that becomes ready after its real worker dies', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const requested = Promise.withResolvers() const ready = Promise.withResolvers() @@ -1244,6 +1251,7 @@ describe('dsh-workflow-worker-thread', () => { it('a worker that exits before settling reports an error result and reaps its children', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) // The child's dispose() REJECTS on top of the worker death: the reap // must contain it (warn, not crash) while still emptying the registry. @@ -1428,6 +1436,7 @@ describe('dsh-workflow-worker-thread', () => { it('unregisters ctx.workflowEngine when the engine fiber is disposed (HMR safety)', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(WorkerThreadWorkflowEngine, {}) expect(ctx.get('workflowEngine')).toBeDefined() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36b32a5873..47cf941286 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -804,6 +804,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -3252,6 +3255,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter @@ -3286,6 +3292,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter @@ -3295,6 +3304,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3329,6 +3341,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -3366,15 +3381,24 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session/session-title packages/context/time-context: dependencies: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3400,6 +3424,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -3412,6 +3439,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3428,6 +3458,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../../shell/shell @@ -3452,6 +3485,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt @@ -3489,6 +3525,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3514,6 +3553,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -3634,6 +3676,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt @@ -3782,6 +3827,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query @@ -3867,6 +3915,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title @@ -3962,6 +4013,9 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../session/session-persistence-sqlite + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -4002,6 +4056,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -4332,6 +4389,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection packages/fs/tool-fs: dependencies: @@ -4384,6 +4444,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -4479,6 +4542,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -4512,6 +4578,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection packages/goal/goal: dependencies: @@ -4579,6 +4648,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -4601,6 +4673,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../goal @@ -4613,6 +4688,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -4647,6 +4725,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -4723,6 +4804,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../../shell/shell @@ -4775,6 +4859,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../../shell/shell @@ -4875,6 +4962,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets @@ -5149,6 +5239,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -5156,6 +5249,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5171,6 +5267,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -5380,6 +5479,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -5423,6 +5525,9 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../session/session-persistence-sqlite + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -5693,6 +5798,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -5783,6 +5891,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -5799,6 +5910,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -5939,6 +6053,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -6036,6 +6153,9 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../session/session-persistence-sqlite + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../session-query @@ -6052,6 +6172,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6067,6 +6190,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../session-query @@ -6121,6 +6247,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -6341,6 +6470,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -6372,6 +6507,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6381,6 +6519,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../session-projection '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../session-title @@ -6403,6 +6544,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6415,6 +6559,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../session-projection '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../session-title @@ -6539,6 +6686,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../shell @@ -6597,6 +6747,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../shell @@ -6698,6 +6851,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../shell @@ -6753,6 +6909,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -6784,6 +6943,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6808,6 +6970,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../shell @@ -6894,6 +7059,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -6913,6 +7081,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -7151,6 +7322,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7200,6 +7374,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7246,6 +7423,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7292,6 +7472,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7329,6 +7512,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7377,6 +7563,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7429,6 +7618,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7481,6 +7673,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7572,6 +7767,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -7639,6 +7837,9 @@ importers: packages/terminal/terminal-bash: dependencies: + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -7713,6 +7914,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -8255,6 +8459,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -8298,6 +8505,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -8365,6 +8575,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 2f1abb810d..2b8b66160d 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -468,7 +468,6 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(LocalJobRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(ToolSubagentControl) await ctx.plugin(ToolSubagentListAgents) }, @@ -669,6 +668,7 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES // plugins mounted still tears the context down (no leaked executor/provider // fiber) — the repo's "dispose must reach quiescence" rule. try { + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime, entry.toolsConfig ?? {}) await entry.mount(ctx) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3c7038937c..f8fe47c8bb 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -628,27 +628,27 @@ { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleProviderId", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleModelProvenance", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleSource", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleEventData", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleSnapshot", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md", @@ -658,7 +658,7 @@ { "doc": "docs/subsystems/session-title.md", "symbol": "SessionTitleUserMessage", - "source": "packages/session/session-title/src/index.ts" + "source": "packages/session/session-title/src/types.ts" }, { "doc": "docs/subsystems/session-title.md", From f364d6ba3783bf1ea83298431f14c36ee8b58849 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 16:58:47 +0800 Subject: [PATCH 03/48] fix: address ds-review-bot findings on the projection migration - llm-retry: validate config before registering the projection unit; document the branded-retry-id zod cast; start stateVersion at 1. - agent-loop: register turnBoundary only after every config validation, so a rejected constructor leaves no unit behind; the defensive-cap test no longer needs fiber cleanup. - agent-instructions: keep newest-first per-scope change history so the latest visible change survives a surface replacement shadowing the newest one (restores the previous scan-visible semantics); add a regression test for the delete-after-shadow sequence. - tool-skill: keep catalog-message history so a shadowed newest catalog message still falls back to the latest visible digest. - session-query-sqlite: drop the unused required sessionProjections injection. - plan-mode: restore the command/done error-drop regression test and the cold-replay command/done fold; drop the inaccurate state-reference comment. - tool-todo: remove a stray blank line; document the turnBoundary reader contract on the projection type. --- .../context/agent-instructions/src/state.ts | 22 ++++--- .../tests/agent-instructions.spec.ts | 66 +++++++++++++++++++ packages/core/agent-loop/src/index.ts | 5 +- .../core/agent-loop/tests/tool-calls.spec.ts | 14 ++-- packages/core/agent/src/types.ts | 9 ++- packages/llm/llm-retry/src/index.ts | 5 +- .../plan/plan-mode/tests/projection.spec.ts | 27 ++++++-- .../session-query-sqlite/src/index.ts | 2 +- packages/skill/tool-skill/src/index.ts | 19 ++++-- packages/todo/tool-todo/src/index.ts | 1 - 10 files changed, 133 insertions(+), 37 deletions(-) diff --git a/packages/context/agent-instructions/src/state.ts b/packages/context/agent-instructions/src/state.ts index e0ade96701..73bac5a82a 100644 --- a/packages/context/agent-instructions/src/state.ts +++ b/packages/context/agent-instructions/src/state.ts @@ -136,7 +136,7 @@ function sameInstructionChange(a: AgentInstructionChange, b: AgentInstructionCha && a.digest === b.digest } -const workspaceInstructionsStateSchema = zod.record(zod.string(), zod.object({ +const workspaceInstructionsStateSchema = zod.record(zod.string(), zod.array(zod.object({ change: zod.object({ action: zod.enum(['set', 'replace', 'remove']), scope: zod.string(), @@ -144,14 +144,14 @@ const workspaceInstructionsStateSchema = zod.record(zod.string(), zod.object({ digest: zod.string().optional(), }), seq: zod.number().int().nonnegative(), -})) +}))) -/** Latest workspace-instruction change by scope. */ +/** Newest-first workspace-instruction change history by scope. */ export type WorkspaceInstructionsState = zod.infer declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { - /** Latest workspace-instruction change by scope. */ + /** Newest-first workspace-instruction change history by scope. */ workspaceInstructions: WorkspaceInstructionsState } } @@ -171,11 +171,13 @@ export function createWorkspaceInstructionsProjection(): ProjectionDefinition<'w if (changes.length === 0) return state let next = state for (const change of changes) { - next = { ...next, [change.scope]: { change, seq: event.seq } } + const record = { change, seq: event.seq } + const history = next[change.scope] + next = { ...next, [change.scope]: history === undefined ? [record] : [record, ...history] } } return next }, - stateVersion: 1, + stateVersion: 2, } } @@ -187,8 +189,12 @@ function visibleInstructionChanges( const visible = new Map() const folded = agent.ctx.get('sessionProjections')?.stateOf(agent.session, 'workspaceInstructions') if (folded === undefined) throw new Error('workspaceInstructions projection is not registered') - for (const [scope, record] of Object.entries(folded)) { - if (visibleSeqs.has(record.seq)) visible.set(scope, record.change) + for (const [scope, history] of Object.entries(folded)) { + // History is newest-first; the latest visible record restores the previous + // scan-visible semantics when a surface replacement shadows the newest + // change but an older one stays visible. + const latestVisible = history.find(record => visibleSeqs.has(record.seq)) + if (latestVisible !== undefined) visible.set(scope, latestVisible.change) } for (const message of authorityMessages) { if (!isWorkspaceContextSource(message.source)) continue diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index d9caebea84..86052dc379 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -3626,6 +3626,72 @@ describe('dynamic nested workspace context injection', () => { } }) + it('still removes a deleted instruction whose latest update is shadowed while an older one stays visible', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'pkg/AGENTS.md'), 'first nested rule') + await write(join(root, 'pkg/deep/file.txt'), 'hello') + const ctx = new Context() + await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = stubAgent(root) + + const first = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-before-update'), + name: 'read', + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, + agent, + }) + const firstSeq = (await appendAdditionalContexts(ctx, agent))! + + await write(join(root, 'pkg/AGENTS.md'), 'second nested rule') + const second = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-after-update'), + name: 'read', + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, + agent, + }) + const secondSeq = (await appendAdditionalContexts(ctx, agent))! + expect(agent.session.surface.nodes).toContain(firstSeq) + expect(agent.session.surface.nodes).toContain(secondSeq) + + // A surface replacement shadows only the latest update; the older one + // stays visible, so the scope still has visible state to lose. + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'compacted summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: secondSeq, end: secondSeq }, + sourceEventSeqs: [secondSeq], + }) + + await rm(join(root, 'pkg/AGENTS.md')) + await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('read-after-delete'), + name: 'read', + arguments: { file_path: join('pkg', 'deep', 'file.txt') }, + agent, + }) + await appendAdditionalContexts(ctx, agent) + + expect(first.additionalContexts).toBeUndefined() + expect(second.additionalContexts).toBeUndefined() + const removal = agent.session.events.find(event => event.type === 'user/message' + && event.data.source.kind === 'agent-instructions' + && event.data.source.changes.some(change => change.action === 'remove')) + expect(removal?.type === 'user/message' ? removal.data.source : undefined).toMatchObject({ + changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], + }) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) + it('re-arms an unchanged baseline after compaction removes it from the surface', async () => { const root = await tempRepo() const home = await tempRepo() diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index d948b0cb24..b3fcea705d 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -378,8 +378,6 @@ export class AgentLoop extends Service implements AgentFactory { constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - const entry: AgentLoopSettings = { maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls), } @@ -406,6 +404,9 @@ export class AgentLoop extends Service implements AgentFactory { onChange: () => {}, }) validateConfiguredAgents(this.config.agents) + // Register only after every config validation above has passed, so a + // rejected constructor leaves no projection unit behind. + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()') diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index a2fad43978..aeba5d1c34 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -269,17 +269,13 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await expect(harness(new MockAdapter([]), 1.5)).rejects.toThrow() }) - it('defensively rejects invalid caps when direct construction bypasses the config schema', async () => { - const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) - expect(() => new AgentLoop(ctx, { agents: [], maxParallelToolCalls: 0 })) + it('defensively rejects invalid caps when direct construction bypasses the config schema', () => { + // Validation precedes the turnBoundary registration, so a rejected + // constructor registers nothing and needs no fiber cleanup. + expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 0 })) .toThrow('maxParallelToolCalls must be a positive integer') - await ctx.fiber.dispose() - const other = new Context() - await other.plugin(SessionProjectionRegistry) - expect(() => new AgentLoop(other, { agents: [], maxParallelToolCalls: 1.5 })) + expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 1.5 })) .toThrow('maxParallelToolCalls must be a positive integer') - await other.fiber.dispose() }) it('defaults the cap when direct construction bypasses the config schema', async () => { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index c7c9bfa0f6..0459219eed 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -9,7 +9,14 @@ import type { UserMessage } from '@deepseek-ai/dsh-llm/types' /** One of the two ordered pending-message lists owned by an agent. */ export type InboxTarget = 'next-turn' | 'next-step' -/** Turn and step boundaries folded from one agent session log. */ +/** + * Turn and step boundaries folded from one agent session log. + * + * Reader contract: the key is registered by `dsh-agent-loop` and absent + * otherwise. Without agent-loop no turn events exist, so readers treat an + * absent key as "no open turn / no boundaries" — capability absence, not a + * corrupt state — and never treat it as an error. + */ export interface TurnBoundaryProjection { /** Open turn number, or null between turns. */ readonly openTurn: number | null diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 95568bab0a..ed95e9485d 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -108,6 +108,7 @@ interface RetryStateEntry { type LlmRetryState = Record +// Zod cannot express the branded retry id without a runtime transform. const llmRetryStateSchema: zod.ZodType = zod.record(zod.string(), zod.object({ retry: zod.number().int().nonnegative(), retryId: zod.string(), @@ -120,9 +121,10 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void { + validateConfig(config) ctx.sessionProjections.register({ key: 'llmRetry', - stateVersion: 2, + stateVersion: 1, stateSchema: llmRetryStateSchema, init: () => ({}), apply: (state, event) => { @@ -134,7 +136,6 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna return { ...state, [key]: { retry: event.data.retry, retryId: event.data.retryId } } }, }) - validateConfig(config) const random = internals.random ?? Math.random const lifetime = new AbortController() const active = new Set>() diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index e4f92cef03..ff41e160a7 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -38,13 +38,20 @@ async function harness(withPlanMode: boolean): Promise { } /** Append one logged /plan selection record (the executor's command/run shape). */ -function runPlanCommand(session: Session, args: string, index: number): void { +function runPlanCommand(session: Session, args: string, index: number): CommandId { + const commandId = CommandId(`plan-proj-${String(index)}`) session.append('command/run', { - commandId: CommandId(`plan-proj-${String(index)}`), + commandId, name: 'plan', args, source: { kind: 'user' }, }) + return commandId +} + +/** Append the paired settlement for one projected plan command. */ +function settlePlanCommand(session: Session, commandId: CommandId, kind: 'success' | 'error'): void { + session.append('command/done', { commandId, kind }) } /** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */ @@ -62,15 +69,23 @@ describe('plan projection unit', () => { it('a logged /plan selection reads pending until plan/mode records it', async () => { const bench = await harness(true) - runPlanCommand(bench.session, '', 0) + const commandId = runPlanCommand(bench.session, '', 0) expect(bench.values().plan).toEqual({ active: false, pending: true }) - // A repeated identical selection returns the same state reference (no frame). - runPlanCommand(bench.session, '', 1) + settlePlanCommand(bench.session, commandId, 'success') expect(bench.values().plan).toEqual({ active: false, pending: true }) commitPlanMode(bench.session, true, 0) expect(bench.values().plan).toEqual({ active: true, pending: false }) }) + it('drops a plan selection when its command settles with an error', async () => { + const bench = await harness(true) + commitPlanMode(bench.session, true, 0) + const commandId = runPlanCommand(bench.session, 'off', 0) + expect(bench.values().plan).toEqual({ active: true, pending: true }) + settlePlanCommand(bench.session, commandId, 'error') + expect(bench.values().plan).toEqual({ active: true, pending: false }) + }) + it('folds `off` args and non-plan commands correctly, and a matching selection is not pending', async () => { const bench = await harness(true) commitPlanMode(bench.session, true, 0) @@ -141,7 +156,7 @@ describe('plan projection unit', () => { // memory involved, the fold alone answers {active:false, pending:true}. const cold = await harness(true) for (const event of bench.session.events) { - if (event.type === 'command/run' || event.type === 'plan/mode') { + if (event.type === 'command/run' || event.type === 'command/done' || event.type === 'plan/mode') { cold.session.append(event.type, event.data) } } diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 4c528e9601..1c4569df4c 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -194,7 +194,7 @@ interface CursorPayload { /** Concrete SQLite owner of the combined `ctx.sessionQuery` service. */ export class SqliteSessionQueryEngine extends SessionQueryEngine { - static override inject = ['sessions', 'sessionProjections'] + static override inject = ['sessions'] static Config: z = z.object({ path: z.string().required(), diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index ac1bbec5fa..b008f0d8fe 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -74,10 +74,10 @@ export const Config: z = z.object({ * The skill-catalog projection's state schema — the one definition of the * state shape; the type is inferred from it (state equals the public shape). */ -const skillCatalogStateSchema = zod.object({ +const skillCatalogStateSchema = zod.array(zod.object({ digest: zod.string().min(1), seq: zod.number().int().nonnegative(), -}).nullable() +})).nullable() type SkillCatalogState = zod.infer @@ -89,7 +89,7 @@ type SkillCatalogState = zod.infer */ declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { - /** The latest published skill-catalog message (digest + seq); null before the first publication. */ + /** Newest-first published skill-catalog messages (digest + seq); null before the first publication. */ skillCatalog: SkillCatalogState } } @@ -225,14 +225,15 @@ export function apply(ctx: Context, config: Config = {}): void { ctx.sessionProjections.register({ key: 'skillCatalog', - stateVersion: 1, + stateVersion: 2, stateSchema: skillCatalogStateSchema, init: () => null, apply: (state, event) => { if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') return state const entries = readCatalogEntries(event.data.source) if (entries === undefined) return state - return { digest: digestCatalogEntries(entries), seq: event.seq } + const record = { digest: digestCatalogEntries(entries), seq: event.seq } + return state === null ? [record] : [record, ...state] }, }) @@ -395,8 +396,12 @@ function catalogHistory(ctx: Context, agent: Agent): { visibleDigest?: string; p const visible = new Set(agent.session.surface.nodes) const state = ctx.sessionProjections.stateOf(agent.session, 'skillCatalog') ?? null if (state === null) return { published: false } - return visible.has(state.seq) - ? { visibleDigest: state.digest, published: true } + // History is newest-first; the latest visible record restores the previous + // scan-visible semantics when a surface replacement shadows the newest + // catalog message but an older one stays visible. + const latestVisible = state.find(record => visible.has(record.seq)) + return latestVisible !== undefined + ? { visibleDigest: latestVisible.digest, published: true } : { published: true } } diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index ef82b2f19d..3fb503a87d 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -136,7 +136,6 @@ export function apply(ctx: Context, config: Config): void { return state }, wire: { viewSchema: todosProjectionSchema, view: state => state }, - }) ctx.tools.register(defineTool({ name: 'todo_write', From b0c2e2bf018ede78e58a13d5c320813cfeae75c2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 16:58:53 +0800 Subject: [PATCH 04/48] refactor(session-title): keep title input as an O(1) projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The titleInput unit no longer retains the full eligible message history in bounded reverse-linked chunks. It folds only {first, last, count} — the values scheduling and fallback reads need — and the full eligible prefix for one provider generation is scanned from the session log at execution time. The projection state is O(1) per session instead of growing with every user message. The README description updates accordingly and drops the inaccurate "latest request route" claim; the projection test now asserts the bounded aggregate and its checkpoint row. --- docs/subsystems/session-title.i18n.yaml | 4 +- docs/subsystems/session-title.md | 2 +- docs/subsystems/session-title.zh.md | 2 +- .../session/session-title/README.i18n.yaml | 4 +- packages/session/session-title/README.md | 2 +- packages/session/session-title/README.zh.md | 2 +- packages/session/session-title/src/index.ts | 41 ++++++++++--------- packages/session/session-title/src/types.ts | 10 +---- .../session-title/tests/projection.spec.ts | 9 ++-- 9 files changed, 35 insertions(+), 41 deletions(-) diff --git a/docs/subsystems/session-title.i18n.yaml b/docs/subsystems/session-title.i18n.yaml index 721bbb0459..e75bf69822 100644 --- a/docs/subsystems/session-title.i18n.yaml +++ b/docs/subsystems/session-title.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 docs/subsystems/session-title.md -session-title.md: d872086500cf477cfa88f8eb756fe9b7be5fc571 -session-title.zh.md: 01aa62a24320e9c9329641603529c1ced9da4360 +session-title.md: 07629a83fa70fa52429e9ca57d75fac6565f5a3e +session-title.zh.md: ea7581f74434204377f7f5e02611e0feb3da144b diff --git a/docs/subsystems/session-title.md b/docs/subsystems/session-title.md index d872086500..07629a83fa 100644 --- a/docs/subsystems/session-title.md +++ b/docs/subsystems/session-title.md @@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](session.md) -Source: [`packages/session/session-title/src/index.ts:282`](../../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:289`](../../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/session-title.zh.md b/docs/subsystems/session-title.zh.md index 01aa62a243..ea7581f744 100644 --- a/docs/subsystems/session-title.zh.md +++ b/docs/subsystems/session-title.zh.md @@ -200,5 +200,5 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](session.md) -Source: [`packages/session/session-title/src/index.ts:282`](../../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:289`](../../packages/session/session-title/src/index.ts) diff --git a/packages/session/session-title/README.i18n.yaml b/packages/session/session-title/README.i18n.yaml index c3210ca41a..07d3b10072 100644 --- a/packages/session/session-title/README.i18n.yaml +++ b/packages/session/session-title/README.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 packages/session/session-title/README.md -README.md: 7949dbdb73d33c6c7ba1cccd469a6b22732aa95d -README.zh.md: 24014485050be9e6aa736ac6c686416c3778ac10 +README.md: f7e18b1163790e65b5f0644909098bf5c55dd6b0 +README.zh.md: 74aee2727eb05a2c57b56f491fdfe598840a696d diff --git a/packages/session/session-title/README.md b/packages/session/session-title/README.md index 7949dbdb73..f7e18b1163 100644 --- a/packages/session/session-title/README.md +++ b/packages/session/session-title/README.md @@ -13,7 +13,7 @@ Only text blocks from human `user/message` events are eligible. The first eligib - `rename(session, title)` accepts an explicit user title synchronously: it normalizes the text, supersedes in-flight automatic work, and appends a `session/title` event with the `user` source. A user-sourced latest title pins the session — later user messages schedule no automatic revision; an explicit `refresh` remains the deliberate unpin. - `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register. -The service also registers the host-only `titleInput` projection. It incrementally retains eligible human text and the latest request route, so provider scheduling reads typed state through `stateOf()` instead of rescanning the session log. +The service also registers the host-only `titleInput` projection. It incrementally retains the first and latest eligible messages plus their count, so scheduling and fallback reads are O(1) through `stateOf()`; the full eligible prefix for one provider generation is scanned from the session log at execution time. Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. diff --git a/packages/session/session-title/README.zh.md b/packages/session/session-title/README.zh.md index 2401448505..74aee2727e 100644 --- a/packages/session/session-title/README.zh.md +++ b/packages/session/session-title/README.zh.md @@ -13,7 +13,7 @@ - `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。用户来源的最新标题会钉住该会话——后续用户消息不再安排自动修订;显式 `refresh` 仍是有意的解钉手段。 - `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;对提供方执行 dispose(资源释放)会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。 -服务还会注册仅供 host 使用的 `titleInput` 投影。该投影增量保留符合条件的人类文本和最近一次请求 route,使提供方调度可以通过 `stateOf()` 读取类型化状态,无需重新扫描会话日志。 +服务还会注册仅供 host 使用的 `titleInput` 投影。该投影增量保留第一条与最新一条合格消息及其计数,使调度与回退读取通过 `stateOf()` 达到 O(1);某次提供方生成所需的完整合格前缀,则会在执行时从会话日志中扫描取得。 自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使请求头未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会立即观察到该事件,并在常规生命周期检查点完成刷写;标题发布本身不会强制刷写。自动失败会发出警告并保留最新标题。新的全消息修订、提供方 dispose、会话 dispose 和显式刷新都会中止旧工作,陈旧的完成结果无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务与内置模型提供方各自追加自己的字面事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算完成。 diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 7f467e6d79..f7d6a553cb 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -31,7 +31,6 @@ import type { SessionTitleSnapshot, SessionTitleSource, SessionTitleUserMessage, - TitleInputChunk, TitleInputState, TitleProjection, } from './types.ts' @@ -211,20 +210,28 @@ export function titleSnapshotFromState(state: TitleUnitState): SessionTitleSnaps }) } -const TITLE_INPUT_CHUNK_SIZE = 64 +const EMPTY_TITLE_INPUT: TitleInputState = { first: null, last: null, count: 0 } -const EMPTY_TITLE_INPUT: TitleInputState = { first: null, last: null, count: 0, tail: null } - -function titleInputPrefix(state: TitleInputState, throughSeq: number): SessionTitleUserMessage[] { - const chunks: TitleInputChunk[] = [] - for (let chunk = state.tail; chunk !== null; chunk = chunk.previous) chunks.push(chunk) - const prefix: SessionTitleUserMessage[] = [] - for (const chunk of chunks.reverse()) { - for (const message of chunk.messages) { - if (message.seq <= throughSeq) prefix.push(message) - } +/** + * Collect eligible human text messages from a session log, in seq order. + * The full eligible prefix is only materialized for one provider generation, + * so it is scanned from the log at execution time rather than retained by + * the O(1) `titleInput` projection. + * @param events - the session event log. + * @param throughSeq - optional inclusive upper seq bound. + * @returns eligible messages with exact source seqs. + */ +function collectSessionTitleMessages( + events: readonly SessionEvent[], + throughSeq?: number, +): SessionTitleUserMessage[] { + const messages: SessionTitleUserMessage[] = [] + for (const event of events) { + if (throughSeq !== undefined && event.seq > throughSeq) break + const message = sessionTitleUserMessageOf(event) + if (message !== undefined) messages.push(message) } - return prefix + return messages } // Zod cannot express the branded provider id without a runtime transform. @@ -326,20 +333,16 @@ export class SessionTitleService extends Service { ctx.sessionProjections.register<'titleInput', TitleInputState>({ key: 'titleInput', - stateVersion: 1, + stateVersion: 2, stateSchema: zod.custom(), init: () => EMPTY_TITLE_INPUT, apply: (state, event) => { const message = sessionTitleUserMessageOf(event) if (message === undefined) return state - const tail = state.tail === null || state.tail.messages.length >= TITLE_INPUT_CHUNK_SIZE - ? { messages: [message], previous: state.tail } - : { messages: [...state.tail.messages, message], previous: state.tail.previous } return { first: state.first ?? message, last: message, count: state.count + 1, - tail, } }, }) @@ -586,7 +589,7 @@ export class SessionTitleService extends Service { this.assertCurrent(session, work) await this.ensureFallback(session) this.assertCurrent(session, work) - const messages = titleInputPrefix(this.titleInputOf(session), work.throughSeq) + const messages = collectSessionTitleMessages(session.events, work.throughSeq) const result = await work.registration.provider.generate({ session, messages, diff --git a/packages/session/session-title/src/types.ts b/packages/session/session-title/src/types.ts index 77c6d3e78e..6f4797d262 100644 --- a/packages/session/session-title/src/types.ts +++ b/packages/session/session-title/src/types.ts @@ -65,13 +65,7 @@ export interface SessionTitleUserMessage { readonly text: string } -/** One bounded reverse-linked group of eligible title-input messages. */ -export interface TitleInputChunk { - readonly messages: readonly SessionTitleUserMessage[] - readonly previous: TitleInputChunk | null -} - -/** Eligible title input stored in bounded reverse-linked chunks. */ +/** Eligible title input stored as a bounded aggregate. */ export interface TitleInputState { /** The oldest eligible message, or null before any. */ readonly first: SessionTitleUserMessage | null @@ -79,8 +73,6 @@ export interface TitleInputState { readonly count: number /** Newest eligible message, or null before any. */ readonly last: SessionTitleUserMessage | null - /** Newest chunk in the reverse-linked list. */ - readonly tail: TitleInputChunk | null } declare module '@deepseek-ai/dsh-session-projection/types' { diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index d518f9f756..74aec3bb70 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -62,7 +62,7 @@ describe('title projection unit', () => { expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false) }) - it('keeps thousands of title inputs in bounded reverse-linked chunks without persisting them', async () => { + it('keeps thousands of title inputs as a bounded aggregate and checkpoints it', async () => { const { ctx, session } = await harness(false) session.append('turn/start', { turn: 1 }) for (let index = 0; index < 5_000; index++) { @@ -75,9 +75,8 @@ describe('title projection unit', () => { const state = ctx.sessionProjections.stateOf(session, 'titleInput') expect(state?.count).toBe(5_000) - let chunks = 0 - for (let chunk = state?.tail ?? null; chunk !== null; chunk = chunk.previous) chunks += 1 - expect(chunks).toBe(Math.ceil(5_000 / 64)) - expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeUndefined() + expect(state?.first?.text).toBe('message 0') + expect(state?.last?.text).toBe('message 4999') + expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeDefined() }) }) From 5ddbc6e71f873bb30eb22f6498e94ce5654c7911 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 16:59:00 +0800 Subject: [PATCH 05/48] refactor(session-projection): checkpoint every projection unit (migration side) Adapts this branch to the base's removal of the persist opt-in: the registry folds and checkpoints every registered unit, the host-only subagent identity drops its explicit persist: true, and the state-and-client-views note records the uniform rule. The cordis API catalog and session-projection subsystem signatures are regenerated. --- ...ession-projection-state-and-client-views.i18n.yaml | 4 ++-- ...08-19-session-projection-state-and-client-views.md | 4 ++-- ...19-session-projection-state-and-client-views.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 ++-- docs/subsystems/session-projection.md | 6 +++--- docs/subsystems/session-projection.zh.md | 6 +++--- packages/extensions/tool-cordis/src/api-catalog.ts | 4 ++-- .../session/session-projection-cache/README.i18n.yaml | 4 ++-- packages/session/session-projection-cache/README.md | 2 +- .../session/session-projection-cache/README.zh.md | 2 +- .../session-projection-cache/tests/cache.spec.ts | 1 - packages/session/session-projection/README.i18n.yaml | 4 ++-- packages/session/session-projection/README.md | 4 ++-- packages/session/session-projection/README.zh.md | 4 ++-- packages/session/session-projection/src/index.ts | 11 ++--------- .../session/session-projection/tests/registry.spec.ts | 1 - packages/subagent/subagent/src/projection.ts | 1 - 17 files changed, 27 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml index d198faddcc..1354aa76d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.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 .agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md -2026-08-19-session-projection-state-and-client-views.md: 928e2ce60c8371f8a1a695b6e9551d26314a55d1 -2026-08-19-session-projection-state-and-client-views.zh.md: 06daf00ee0aa275035991cda4df56b97bba227db +2026-08-19-session-projection-state-and-client-views.md: 0b1810b27594e7f18af5afca67ba39b0cb0f3cc3 +2026-08-19-session-projection-state-and-client-views.zh.md: df6b18fab1a3b37bb11bc70bb86a625f76af2421 diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md index 928e2ce60c..0b1810b275 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md @@ -12,7 +12,7 @@ The projection registry persisted each unit's internal fold state without a runt `SessionProjectionStateMap` is the merge-extensible table for host fold states. Every `ProjectionDefinition` key belongs to this table and supplies a `stateSchema`; cached rows are validated before they seed a fold. `SessionProjectionMap` retains its existing meaning and name as the sole table of client-visible whole values, preserving existing client data structures such as `title: string | null`. -A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Client-visible units are always checkpointed. A host-only unit omits `wire` and is checkpointed only when `persist` is true. Carrier reads use `wireOnly` so internal states do not enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated. +A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Every unit's state is checkpointed — client-visible and host-only alike; the `persist` opt-in is gone, so no unit can silently skip the durable cache. Carrier reads use `wireOnly` so internal states do not enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated. ## Consequences @@ -24,5 +24,5 @@ The original [session-projection proposal](../../proposed/architecture/2026-07-2 - **Rename the existing map to a state table and introduce a new client map** — rejected because it changes the established client type name and invites unnecessary client payload migrations. - **Keep one table for both state and client values** — rejected because a richer fold state and a compatibility-preserving client value then cannot be represented accurately. -- **Persist every host-only unit** — rejected because persistence is a cold-read optimization with storage cost; an internal unit opts in only when its consumers need cold reconstruction. +- **Opt-in persistence for host-only units** — rejected: a `persist` flag lets a unit silently skip the durable cache, and the savings (one small row per session) never justify the asymmetry or the stateVersion confusion it invites. Every unit's state is checkpointed uniformly. - **Return copied state from `stateOf`** — rejected because cloning every host read adds work without protecting a boundary; the method documents a readonly borrowed-reference obligation for typed same-process callers. diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md index 06daf00ee0..df6b18fab1 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md @@ -24,5 +24,5 @@ - **把既有类型表改名为状态表,再引入新的客户端类型表**——不予采用,因为这会改变已经确立的客户端类型名称,并导致不必要的客户端载荷迁移。 - **继续用一张类型表同时描述状态与客户端值**——不予采用,因为这样无法准确表达更丰富的折叠状态和保持兼容的客户端值。 -- **持久化所有 host-only 单元**——不予采用,因为持久化是带存储成本的冷读优化;内部单元只有在消费方需要冷重建时才选择加入。 +- **host-only 单元按需选择持久化**——不予采用:`persist` 标志会让单元悄悄跳过持久化缓存,而省下的(每会话一行小记录)永远不值得这种不对称或它带来的 stateVersion 困惑。每个单元的状态统一写入检查点。 - **让 `stateOf` 返回状态副本**——不予采用,因为每次 host 读取都克隆会增加工作,却没有保护任何边界;该方法为同进程类型化调用方明确规定只读借用引用义务。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index de2d3209b4..33f7103a9e 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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 docs/subsystems/session-projection.md -session-projection.md: 3a354c081e38d23eb5ba337f3d4973788f1fe124 -session-projection.zh.md: 378b22faff90d15b3e34dc31112597a64a3a7b44 +session-projection.md: a0704206904880259425b6b32b648d383d815fb2 +session-projection.zh.md: 74392356d980e83b1ca281732b916f7d9e678702 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 3a354c081e..a070420690 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -100,7 +100,7 @@ type ProjectionChangeListener = ( ## The registry: `ctx.sessionProjections` -`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence. Unit contributors and host readers require the registry, so an incomplete composition fails during activation ([decision](../../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md)). Registrants of the same compatible definition share the unit until the last registration is disposed. +`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence. Unit contributors and host readers require the registry, so an incomplete composition fails during activation ([decision](../../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)). Registrants of the same compatible definition share the unit until the last registration is disposed. @@ -180,7 +180,7 @@ register< K extends keyof SessionProjectionMap, S extends SessionProjectionState /** * Register one host-only unit. Its state is omitted from client snapshots - * and persisted only when `persist` is true. + * and always checkpointed like every other unit. * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ @@ -289,5 +289,5 @@ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], base Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:186`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts:183`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 378b22faff..74392356d9 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -100,7 +100,7 @@ type ProjectionChangeListener = ( ## 注册表:`ctx.sessionProjections` -`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失。单元贡献方与 host 读取方要求该注册表,因此不完整的组合会在激活时失败([决策](../../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md))。相同且兼容定义的多个注册者共享该单元,直到最后一项注册被 dispose。 +`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失。单元贡献方与 host 读取方要求该注册表,因此不完整的组合会在激活时失败([决策](../../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md))。相同且兼容定义的多个注册者共享该单元,直到最后一项注册被 dispose。 @@ -180,7 +180,7 @@ register< K extends keyof SessionProjectionMap, S extends SessionProjectionState /** * Register one host-only unit. Its state is omitted from client snapshots - * and persisted only when `persist` is true. + * and always checkpointed like every other unit. * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ @@ -289,5 +289,5 @@ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], base Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/session/session-projection/src/index.ts:186`](../../packages/session/session-projection/src/index.ts) +Source: [`packages/session/session-projection/src/index.ts:183`](../../packages/session/session-projection/src/index.ts) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 1a4c6443ee..d86c5d80d0 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1120,7 +1120,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'>, ): () => void', - description: 'Register one host-only unit. Its state is omitted from client snapshots and persisted only when `persist` is true.', + description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], returns: 'the exact disposer that unregisters this unit.', }, @@ -3633,7 +3633,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionDefinition', - declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n persist?: boolean;\n init(): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', + declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', }, { name: 'ProjectionSnapshot', diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 0a07312a70..cf9920a547 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/README.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 packages/session/session-projection-cache/README.md -README.md: 51469918667a249e38167326d6ca33d2dda7d1e2 -README.zh.md: c701f2c044a330c8f5381aecdbffba12eacda305 +README.md: 8c2e3b788209fa51804b9203fa74f80c1142f82a +README.zh.md: 2a0064ecad6a87154c6dd3e45cb5c7e9fb8d037c diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 5146991866..8c2e3b7882 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every client-visible or explicitly persisted projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). +The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to: diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index c701f2c044..2a0064ecad 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -持久投影缓存(`ctx.sessionProjectionCache`):把每个 client-visible 或显式选择持久化的投影单元状态保存为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。 +持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。 一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index bd617f08d1..d09d849a9b 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -184,7 +184,6 @@ describe('SessionProjectionCache write policy', () => { ctx.sessionProjections.register({ key: 'cache-test/marks2', stateSchema: z.custom>(() => true), - persist: true, init: () => new Map(), apply: state => state, stateVersion: 1, diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index a6f003b550..4a90fc44fe 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.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 packages/session/session-projection/README.md -README.md: bbdb9fc172bc3be36a7594772eeef5111f922eaf -README.zh.md: 058fa6da0b1d43a438552d2c554e9f968d50ebb6 +README.md: fe6acef7e0ed5a185ec719fd95335a77ba74f536 +README.zh.md: 5f77625cdbbbc7e3426a8de0cca96fa8c74f8685 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index bbdb9fc172..fe6acef7e0 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -17,7 +17,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - `SessionProjectionMap` — the merge-extensible client-view table shared by wire blocks and client hooks. Values are wire-JSON whole values; rendering belongs to the slot system, never this layer. - `SessionProjectionStateMap` — the merge-extensible host fold-state table. Every client-visible key appears in both tables; host-only keys appear only here. -- `ProjectionDefinition` — `{ key, stateSchema, init(), apply(state, event), wire?, persist?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only. +- `ProjectionDefinition` — `{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only. ## Contract @@ -25,7 +25,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream. - **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers). - **Synchronous unit discipline.** `init`/`apply`/`wire.view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally async view returns a Promise, which fails `wire.viewSchema.parse`. -- **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Client-visible units persist automatically; a host-only unit opts in with `persist: true`. +- **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Every unit's state is checkpointed — client-visible and host-only alike. - **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them. - **Required for units and host reads.** Plugins that contribute or read projection units inject `sessionProjections`, so incomplete compositions fail during activation. The lower-level api-proxy factory remains tolerant for isolated tests and diagnostics and omits projection blocks and frames when the registry is absent. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 058fa6da0b..5f77625cdb 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -17,7 +17,7 @@ - `SessionProjectionMap`——协议块与客户端钩子共享的 merge-extensible client view 表。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。 - `SessionProjectionStateMap`——merge-extensible host 折叠状态表。每个 client-visible key 同时出现在两个表中;host-only key 只出现在这里。 -- `ProjectionDefinition`——`{ key, stateSchema, init(), apply(state, event), wire?, persist?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。 +- `ProjectionDefinition`——`{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。 ## 约定 @@ -25,7 +25,7 @@ - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。 - **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 - **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 -- **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。client-visible 单元自动持久化;host-only 单元以 `persist: true` 选择持久化。 +- **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁。 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 - **单元与 host 读取方必需。** 贡献或读取投影单元的插件注入 `sessionProjections`,因此不完整的组合会在激活时失败。较低层的 api-proxy factory 仍对隔离测试和诊断保持容错,注册表缺席时省略投影块与帧。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index d9d758c5ae..7adfc24c0e 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -47,8 +47,6 @@ export interface ProjectionDefinition< key: K /** Validates persisted state before it seeds a fold. */ stateSchema: ZodType - /** Persist a host-only unit. Client-visible units are always persisted. */ - persist?: boolean /** * State for the empty log. * @returns the initial state. @@ -139,7 +137,6 @@ interface ErasedDefinition { apply(state: unknown, event: SessionEvent): unknown wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined stateVersion: number - persist: boolean } /** Per-session per-unit watermark cache row. */ @@ -214,7 +211,7 @@ export class SessionProjectionRegistry extends Service { ): () => void /** * Register one host-only unit. Its state is omitted from client snapshots - * and persisted only when `persist` is true. + * and always checkpointed like every other unit. * @param definition - key, state schema, pure unit functions, and stateVersion. * @returns the exact disposer that unregisters this unit. */ @@ -240,7 +237,6 @@ export class SessionProjectionRegistry extends Service { ? undefined : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) }, stateVersion: definition.stateVersion, - persist: wire !== undefined || definition.persist === true, } if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) { throw new Error(`session projection ${JSON.stringify(definition.key)} stateVersion must be a non-negative integer, got ${String(definition.stateVersion)}`) @@ -341,7 +337,6 @@ export class SessionProjectionRegistry extends Service { checkpoint(session: Session): ProjectionCheckpoint { const rows: ProjectionCheckpoint = {} for (const registration of this.registrations.values()) { - if (!registration.def.persist) continue const cell = this.cellFor(registration, session) rows[registration.def.key] = { ver: registration.def.stateVersion, @@ -371,7 +366,6 @@ export class SessionProjectionRegistry extends Service { restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined { let floor: number | undefined for (const registration of this.registrations.values()) { - if (!registration.def.persist) continue const row = checkpoint[registration.def.key] const need = row !== undefined && row.ver === registration.def.stateVersion ? Math.max(row.seq + 1, 0) @@ -399,7 +393,7 @@ export class SessionProjectionRegistry extends Service { const values: Record = {} for (const registration of this.registrations.values()) { const def = registration.def - if (!def.persist || (options?.wireOnly === true && def.wire === undefined)) continue + if (options?.wireOnly === true && def.wire === undefined) continue const row = checkpoint[def.key] if (row === undefined || row.ver !== def.stateVersion) continue let state: unknown @@ -450,7 +444,6 @@ export class SessionProjectionRegistry extends Service { const refreshed: ProjectionCheckpoint = {} for (const registration of this.registrations.values()) { const def = registration.def - if (!def.persist) continue const row = checkpoint[def.key] const usable = row !== undefined && row.ver === def.stateVersion diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 2c91a0822b..459e8a1a5b 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -51,7 +51,6 @@ const marksUnit = (): ProjectionDefinition<'test/marks', MarksState> const countUnit = (): ProjectionDefinition<'test/count', number> => ({ key: 'test/count', stateSchema: z.number().int().nonnegative(), - persist: true, init: () => 0, apply: state => state + 1, stateVersion: 1, diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index d0185144d3..e10ad73da7 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -153,7 +153,6 @@ export const subagentIdentityProjectionDefinition = { key: 'subagent', stateVersion: 3, stateSchema: identitySchema, - persist: true, init: () => null, apply: (state, event) => { if (event.type !== 'subagent/descriptor') return state From 86831ea9a2d19c15e97dd0c53f5a5ebfd3711c77 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 16:59:07 +0800 Subject: [PATCH 06/48] docs(notes): date the mandatory projection-seam note 2026-08-19 The note was committed on 2026-08-19 but filed under 2026-08-07 while building on the 08-19 state-and-client-views note; rename the triplet to align the filename with the actual date and update the inbound architecture links. --- ... 2026-08-19-session-projection-mandatory-seam.i18n.yaml} | 6 +++--- ...m.md => 2026-08-19-session-projection-mandatory-seam.md} | 2 +- ...d => 2026-08-19-session-projection-mandatory-seam.zh.md} | 2 +- docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) rename .agents/notes/implemented/architecture/{2026-08-07-session-projection-mandatory-seam.i18n.yaml => 2026-08-19-session-projection-mandatory-seam.i18n.yaml} (56%) rename .agents/notes/implemented/architecture/{2026-08-07-session-projection-mandatory-seam.md => 2026-08-19-session-projection-mandatory-seam.md} (97%) rename .agents/notes/implemented/architecture/{2026-08-07-session-projection-mandatory-seam.zh.md => 2026-08-19-session-projection-mandatory-seam.zh.md} (97%) diff --git a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml similarity index 56% rename from .agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml index 78b8b2b683..5a3db3ae1f 100644 --- a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml @@ -1,6 +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 .agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md -2026-08-07-session-projection-mandatory-seam.md: 6348ae9afdb1689cd4885e658651d1e5a1f44a4b -2026-08-07-session-projection-mandatory-seam.zh.md: 95f3489275f67fc18a030345e120e2cb8c88c191 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md +2026-08-19-session-projection-mandatory-seam.md: 9edbece131cdc014f2cdf2ed0199b492b46b3ed1 +2026-08-19-session-projection-mandatory-seam.zh.md: e49081759682315f3503ee3114bebbf60c2d82e9 diff --git a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md similarity index 97% rename from .agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md rename to .agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md index 6348ae9afd..9edbece131 100644 --- a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md @@ -2,7 +2,7 @@ Status: implemented -English | [中文](2026-08-07-session-projection-mandatory-seam.zh.md) +English | [中文](2026-08-19-session-projection-mandatory-seam.zh.md) ## Problem diff --git a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md similarity index 97% rename from .agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.zh.md rename to .agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md index 95f3489275..e490817596 100644 --- a/.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md @@ -2,7 +2,7 @@ Status: implemented -[English](2026-08-07-session-projection-mandatory-seam.md) | 中文 +[English](2026-08-19-session-projection-mandatory-seam.md) | 中文 ## 问题 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 33c813ae3f..da21ba42a0 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 docs/architecture.md -architecture.md: 3ad9dec77f59af84594057e74602a1cc6c31dd18 -architecture.zh.md: e3e2128a71fd9b3abe915e9af2bfe1322f450661 +architecture.md: 764cc1a88fec2629689d7cece4943b2a9af4ab46 +architecture.zh.md: 88b2725a64bf05614453602c067379309d19e94f diff --git a/docs/architecture.md b/docs/architecture.md index 3ad9dec77f..764cc1a88f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,7 +95,7 @@ The session log is the source of the context the model sees. `deriveMessages()` **Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend `SessionEventMap` and render from the log. -**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. Projection contributors and readers require this service, so an incomplete composition fails during activation. The agent loop registers shared `turnBoundary` state instead of making each consumer scan the log ([decision](../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md)). +**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. Projection contributors and readers require this service, so an incomplete composition fails during activation. The agent loop registers shared `turnBoundary` state instead of making each consumer scan the log ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)). ## Capability seams diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index e3e2128a71..88b2725a64 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -99,7 +99,7 @@ turn/end **模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。因此,新增一项模型可见输入就需要新增一个会话事件:扩展 `SessionEventMap` 并从日志渲染。 -**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。投影贡献方和读取方都要求该服务,因此不完整的组合会在激活时失败。agent loop 注册共享的 `turnBoundary` 状态,避免每个消费方各自扫描日志([决策](../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md))。 +**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。投影贡献方和读取方都要求该服务,因此不完整的组合会在激活时失败。agent loop 注册共享的 `turnBoundary` 状态,避免每个消费方各自扫描日志([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md))。 ## 能力 seam From 2a4f6541d67077227573edcdfd3d74fa454f54c9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 17:46:02 +0800 Subject: [PATCH 07/48] fix: revert the projection-registration form per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the imccyu review, the pre-existing goal, permissions, and plan units go back to registering through the ctx.inject(['sessionProjections'], …) child form instead of the required-inject direct register; goal returns to zero diff (its service never reads projections). The mandatory-seam rework for these three sites moves to a follow-up PR. New projection units keep the required-inject direct-register form. Regenerated catalog and subsystem docs follow the reverted service signatures. --- docs/subsystems/goal.i18n.yaml | 4 ++-- docs/subsystems/goal.md | 2 +- docs/subsystems/goal.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 ++-- packages/goal/goal/src/index.ts | 24 ++++++++++++------- packages/goal/goal/tests/goal.spec.ts | 9 ------- packages/goal/goal/tests/projection.spec.ts | 10 +++++++- .../permission-presets/src/index.ts | 18 ++++++++------ packages/plan/plan-mode/src/index.ts | 5 +++- 9 files changed, 45 insertions(+), 33 deletions(-) diff --git a/docs/subsystems/goal.i18n.yaml b/docs/subsystems/goal.i18n.yaml index 3e48766d14..df731a5dba 100644 --- a/docs/subsystems/goal.i18n.yaml +++ b/docs/subsystems/goal.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 docs/subsystems/goal.md -goal.md: e5c8d77e7a897b097e7801f65024612d233319a9 -goal.zh.md: a4c8f26173a1ed4b991beaa02de7894258f50b6d +goal.md: 676b2f49d00681ac7f05b894cda0157ebba4cfcd +goal.zh.md: 1d46bc2c9ba1f3b35026ff132d323e46ccd3bc19 diff --git a/docs/subsystems/goal.md b/docs/subsystems/goal.md index e5c8d77e7a..676b2f49d0 100644 --- a/docs/subsystems/goal.md +++ b/docs/subsystems/goal.md @@ -247,7 +247,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView Types: [Agent](core.md) -Source: [`packages/goal/goal/src/index.ts:182`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts) diff --git a/docs/subsystems/goal.zh.md b/docs/subsystems/goal.zh.md index a4c8f26173..1d46bc2c9b 100644 --- a/docs/subsystems/goal.zh.md +++ b/docs/subsystems/goal.zh.md @@ -247,7 +247,7 @@ block(agent: Agent, ref: GoalRef, reason: GoalBlockReason): GoalView Types: [Agent](core.md) -Source: [`packages/goal/goal/src/index.ts:182`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:183`](../../packages/goal/goal/src/index.ts) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 33f7103a9e..5331ba0581 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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 docs/subsystems/session-projection.md -session-projection.md: a0704206904880259425b6b32b648d383d815fb2 -session-projection.zh.md: 74392356d980e83b1ca281732b916f7d9e678702 +session-projection.md: edc1d646c01cb95ccc090f69f3868e466c346fdf +session-projection.zh.md: 9c10737ef8b6d409155039632da86f0573c05f13 diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 045c773bcd..baf8c921a9 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -13,6 +13,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' +// Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' import { applyGoalEvent, @@ -49,7 +50,7 @@ import type { // The pure payload outlet (./types.ts, ONE home of the `goal` projection-key // declaration) re-exported onto the package root keeps the module edge in // the emitted index.d.ts, so aggregate programs consuming the declarations -// still receive the SessionProjectionStateMap merge. +// still receive the SessionProjectionMap merge. export type * from './types.ts' export type * from './domain.ts' export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' @@ -180,7 +181,7 @@ function resolveBlockReason(reason: unknown): GoalBlockReason { /** Goal service (`ctx.goals`) backed exclusively by the owning session log. */ export class GoalService extends TypertRemoteService { - static inject = ['agents', 'sessionProjections'] + static inject = ['agents'] static Config: z = z.object({ defaultMaxGoalRounds: z.number().default(256), @@ -197,13 +198,18 @@ export class GoalService extends TypertRemoteService { ctx.on('agent/session-start', ({ agent }) => { this.cache(agent.session).activation = 'disarmed' }) - ctx.sessionProjections.register<'goal', GoalProjection | null>({ - key: 'goal', - stateVersion: 4, - stateSchema: goalProjectionSchema, - init: () => null, - apply: applyGoalProjection, - wire: { viewSchema: goalProjectionSchema, view: state => state }, + // The `goal` projection unit: last-wins fold of goal/change whole values + // (see applyGoalProjection). The unit child activates only when a + // projection registry is composed (headless assemblies stay unaffected). + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({ + key: 'goal', + stateSchema: goalProjectionSchema, + init: () => null, + apply: applyGoalProjection, + wire: { viewSchema: goalProjectionSchema, view: state => state }, + stateVersion: 4, + }) }) } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 1eb0c90d2e..e94e0754a0 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -4,7 +4,6 @@ import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import GoalService, { GoalError, GoalId, @@ -61,7 +60,6 @@ function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-sessi async function harness(config: { defaultMaxGoalRounds?: number } = {}) { const ctx = new Context() await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService, config) const stub = stubAgent(`goal-test-${Math.random()}`) ctx.agents.register(stub.agent) @@ -133,7 +131,6 @@ describe('GoalService creation and replay', () => { it('also resolves the default when constructed directly without Cordis config normalization', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) const goals = new GoalService(ctx) const stub = stubAgent('goal-direct-construction') ctx.agents.register(stub.agent) @@ -145,7 +142,6 @@ describe('GoalService creation and replay', () => { it('rejects invalid direct configuration', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await expect(ctx.plugin(GoalService, { defaultMaxGoalRounds: -1 })).rejects.toThrow(expect.objectContaining({ code: 'GOAL_INVALID_MAX_ROUNDS', })) @@ -159,7 +155,6 @@ describe('GoalService creation and replay', () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const resumed = stubAgent('seeded-goal', first.session.events) ctx.agents.register(resumed.agent) @@ -174,7 +169,6 @@ describe('GoalService creation and replay', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent'))) ctx.agents.register(parent.agent) @@ -221,7 +215,6 @@ describe('GoalService creation and replay', () => { it('removes the service and its session-start listener with the providing fiber', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(GoalService) const first = ctx.goals const stub = stubAgent('goal-hmr') @@ -430,7 +423,6 @@ describe('GoalService mutations', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer'))) ctx.agents.register(stub.agent) @@ -449,7 +441,6 @@ describe('GoalService mutations', () => { it('does not delegate goal persistence to agent injection', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const stub = stubAgent('goal-independent-injection') stub.agent.inject = () => { throw new Error('injection must not be called') } diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index e5fb642e84..6282a6f739 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -1,4 +1,12 @@ -/** Goal projection behavior. */ +/** + * The `goal` projection unit: mounting GoalService beside the registry + * serves the current whole goal on the history tail page with a consistent + * asOfSeq; before the first create the value is null; a clear tombstone + * returns it to null; a composition without the goal service has no `goal` + * key; unmounting drops it (HMR safety). Malformed goal-shaped events are + * ignored fail-soft (same-reference return) — strict replay validation + * belongs to the write side and foldGoal, never the projection drive. + */ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index 67b7f523ca..11822ce4af 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -208,13 +208,17 @@ export class PermissionPresetService extends Service { })), currentValue: zod.string().min(1), }) as unknown as zod.ZodType - ctx.sessionProjections.register({ - key: 'permissions', - stateVersion: 1, - stateSchema: knobStateSchema, - init: () => EMPTY_KNOBS, - apply: applyKnobEvent, - wire: { viewSchema: selectSchema, view: state => this.selectFor(state) }, + // The `permissions` projection unit folds the three whole-value knob + // events; it registers through the projection registry. + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register({ + key: 'permissions', + stateVersion: 1, + stateSchema: knobStateSchema, + init: () => EMPTY_KNOBS, + apply: applyKnobEvent, + wire: { viewSchema: selectSchema, view: state => this.selectFor(state) }, + }) }) // The /permission command: the one write path a web client uses (the diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 676baef697..4a8924d0b7 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -217,7 +217,10 @@ export class PlanModeController extends Service { }, }) - ctx.sessionProjections.register(planProjectionDefinition) + // The `plan` projection unit registers through the projection registry. + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register(planProjectionDefinition) + }) // The command child activates only when a command registry is composed. ctx.inject(['commands'], (commandCtx) => { From 58ebaa63d0ddf7601fdfbaaf80e7294e4168dbfd Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 17:46:11 +0800 Subject: [PATCH 08/48] docs(notes): refresh superseded projection notes for the mandatory seam Update in place the implemented notes whose mechanisms this stack changed: the subagent list identity note drops the deleted SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE error contract and hostile-unit probe for the required-injection seam; the durable-subagent-catalog note drops the same stale error-code reference; the sandbox pair replaces the effectiveSandboxMode/effectiveApprovalPolicy trio with the sandboxMode projection unit on the required registry; the plan collaboration note replaces foldPlanMode with the plan projection unit. EN/ZH in lock-step; i18n pairing re-recorded. --- ...ubagent-list-identity-projection.i18n.yaml | 4 +- ...08-06-subagent-list-identity-projection.md | 51 ++++++++++--------- ...06-subagent-list-identity-projection.zh.md | 51 ++++++++++--------- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 13 ++--- .../feature/2026-07-06-sandbox.zh.md | 13 ++--- ...26-07-14-cross-family-fs-sandbox.i18n.yaml | 4 +- .../2026-07-14-cross-family-fs-sandbox.md | 8 +-- .../2026-07-14-cross-family-fs-sandbox.zh.md | 8 +-- ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 4 +- ...ble-subagent-catalog-and-list-agents.zh.md | 4 +- ...lan-specific-collaboration-state.i18n.yaml | 4 +- ...07-22-plan-specific-collaboration-state.md | 2 +- ...22-plan-specific-collaboration-state.zh.md | 2 +- 15 files changed, 94 insertions(+), 82 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index 3d9d0c8480..7f3103c5c7 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.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 .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: 2e88639eade2b5a83ef491bcb93693d432bd676b -2026-08-06-subagent-list-identity-projection.zh.md: acd1628afc69c9620d2724a53320cdd56b89c4cb +2026-08-06-subagent-list-identity-projection.md: a4fd153f3c052b684146f1c488e3c8f12f8f7cb0 +2026-08-06-subagent-list-identity-projection.zh.md: 9680a96310c203b6bb540f7faf1eb5b89ce13d17 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index 2e88639ead..a4fd153f3c 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -14,25 +14,25 @@ The root cause is that the [durable-subagent-catalog decision](../feature/2026-0 ## Decision -mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional `sessionProjectionCache` checkpoint, and a served identity that passes the seq gate is final; otherwise it pays one full `persistence.inspect` read plus one `registry.restore` fold. No index, no cache of its own, no write-back. +mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional `sessionProjectionCache` checkpoint, and a served identity that passes the seq gate is final; otherwise it pays one full `persistence.inspect` read plus a fold through the registered `subagent` unit. No index, no cache of its own, no write-back. There are three families of escape from the per-child scan: promote mode/label into the header (the write path pays); build a durable derivation for the projection (a checkpoint ladder, or values landed during query-index rebuild with read-side reconciliation); or compute at read time (live from the watermark cache, cold from one full read). This note takes the third. "Values landed with the query index" was retired wholesale: query infrastructure was forced to learn domain vocabulary while the sole consumer is satisfied by read-time computation — the live child's zero reads come for free from session-projection's existing watermark cache, and the cold child's single full read is explicitly accepted as compute-and-discard. The first two routes and the retirement rationale are detailed under Alternatives considered. Key points: - **The subagent list does not depend on session-query**: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through `ctx.sessionProjections`; deployments without a query backend list as usual. -- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header)`, using the value directly when a non-null `subagent` identity passing the seq gate (`seq >= seedLength ?? 0`) is among its values; otherwise it pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache of its own, no write-back, no index. -- **The `subagent` projection unit is the sole authority over the fold rules**: the live snapshot, the cold restore, and GUI history's detached fold all compute through the registry; no second copy of descriptor-interpretation logic exists. +- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.stateOf(session, 'subagent')` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header)`, using the value directly when a non-null `subagent` identity passing the seq gate (`seq >= seedLength ?? 0`) is among its values; otherwise it pays one full `persistence.inspect` read plus a fold through the registered `subagent` unit; beyond that, absent is absent — no cache of its own, no write-back, no index. +- **The `subagent` projection unit is the sole authority over the fold rules**: the live `stateOf` read, the cold unit fold, and GUI history's detached fold all run the one registered unit; no second copy of descriptor-interpretation logic exists. - **The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched**; pre-existing data acquires exact values through one `inspect` computation the first time it is listed — no degraded unknown state, no migration. Relationship to existing notes: - This note supersedes two designs on the list read path in [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md): enumeration through `sessionQuery.traceSession`, and per-child descriptor-event reads (the `listEvents`-plus-exact-`readEvent` double read with in-place diagnostic classification). The diagnostic row semantics is retained, with classification now derived by the list from projection-value absence and activity; the descriptor event remains the sole durable authority for mode/label and the fold input, and the resume authorization and Activation contracts are untouched. This is partial supersession; the two notes stay cross-linked. -- The [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)'s registry contract (`ProjectionDefinition`, `snapshot`, `restore`) is untouched; this note only adds one registration to it — the `subagent` identity unit — and becomes another consumer instance of the two existing reads, snapshot (live) and restore (cold) — GUI history's cold read is already the same shape. The fold rules are registered with the registry exactly once; every consuming surface computes through the registry, and no second copy of the fold logic exists. +- The [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) is the authority for the registry contract, split by the later [state-and-client-views note](2026-08-19-session-projection-state-and-client-views.md); this note only adds one registration to it — the `subagent` identity unit, host-only state — and consumes it through the live `stateOf` read and a cold fold of the same unit (GUI history's cold read is the same detached-restore shape). The fold rules are registered with the registry exactly once; every consuming surface computes through the one registered unit, and no second copy of the fold logic exists. ### `subagent` projection unit -It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packages/subagent/subagent/src/projection.ts), [projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)), under key `subagent`: +It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packages/subagent/subagent/src/projection.ts), [projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)), under key `subagent` — host-only state, while `subagentTiming` keeps the client wire view: ```ts ignore-check export type SubagentIdentityProjection = @@ -40,15 +40,18 @@ export type SubagentIdentityProjection = | { mode: 'continuable'; label: string; seq: number } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionMap { + interface SessionProjectionStateMap { subagent: SubagentIdentityProjection | null } + interface SessionProjectionMap { + subagentTiming: SubagentTimingProjection + } } ``` -- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is a **serializable null sentinel**: the map entry is `SubagentIdentityProjection | null`, non-optional, never undefined or an absent key. The reason: the registry's onChanged push goes through JSON serialization, where an undefined field is dropped by stringify, the client's frame validation rejects the frame, and a consumer's stored old identity would never update; null passes frames intact, and consumers replace the old identity with the sentinel. The judging discipline: consuming surfaces treat null and undefined (which only a JSON boundary dropping the key can produce) alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below). +- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is a **serializable null sentinel**: the map entry is `SubagentIdentityProjection | null`, non-optional, never undefined or an absent key. The reason: the unit's state is persisted as plain JSON, where an undefined field is dropped by stringify and a dropped key is indistinguishable from an absent value on the read side — a stale identity would survive in place; `null` passes JSON losslessly, and consumers replace the old identity with the sentinel. The judging discipline: consuming surfaces treat null and undefined (which only a JSON boundary dropping the key can produce) alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below). - Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; the mode/label discriminant matches the child row's strong contract below exactly (the row carries no `seq` — it is the projection's internal own-suffix proof). -- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel — `seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The state gaining `seq` bumps the unit's `stateVersion` to 2, and existing checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold. +- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel — `seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The unit is host-only — no client wire view, so its state never enters client snapshots or `onChanged` frames — and it is checkpointed like every unit (the `persist` opt-in is gone); its `stateVersion` is 3: adding `seq` bumped it to 2, and the state/client-views split changing the fold state to the direct `SubagentIdentityProjection | null` sentinel bumped it to 3. Existing checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold. - Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to the null sentinel rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up. ### Enumeration: subagent-owned live-preferred merge @@ -68,12 +71,12 @@ For each enumerated child, mode/label retrieval walks a three-rung ladder — co | Rung | Read | Cost | | --- | --- | --- | -| 1: live child | `ctx.sessionProjections.snapshot(session).values.subagent` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | +| 1: live child | `ctx.sessionProjections.stateOf(session, 'subagent')` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | | 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header)`, used directly only when a non-null `subagent` identity satisfies `identity.seq >= header.seedLength ?? 0` — an own descriptor is immutable once appended, and the seq gate proves the value was folded from the child's own suffix, regardless of the row's watermark | Zero log reads | -| 3: cold child, fallback | One full `persistence.inspect(id)` read + `registry.restore({}, events, 0).snapshot.values.subagent` | One full read computed per listing | +| 3: cold child, fallback | One full `persistence.inspect(id)` read + a fold through the registered `subagent` unit | One full read computed per listing | -- Error contract: an unmounted `ctx.sessionProjections` is a configuration error; `listChildren` checks unconditionally before enumerating and fails loudly with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` — a deployment with zero children fails just as deterministically, so an empty listing cannot mask the misconfiguration. The session store gets the same posture: an absent `ctx.get('sessions')` (a strict global read, never the caller-scope-bound property proxy) fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. The two codes map differently on the wire: apiproxy gives only `PROJECTIONS_UNAVAILABLE` a dedicated wire face, and `SESSION_STORE_UNAVAILABLE` goes through the generic internal fallback — the apiproxy composition injects `sessions` itself, so that error is unreachable in its deployment, and a dedicated mapping would violate the need principle. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency. -- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`' loud contract). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing; a null sentinel in the row does not count either — it falls to the third rung for the authoritative refold's verdict. A count/interval checkpoint inside the creation window can land a fork seed's replayed ancestor identity in the row — the ancestor's seq falls inside the seed range, the seq gate rejects it, and it likewise falls to the third rung's verdict. +- Error contract: `sessionProjections` is a required injection — `SubagentRuntime` declares it in its inject set, so a deployment without the registry never activates the service (or the loop), and `listChildren` is unreachable rather than served degraded rows ([mandatory-seam note](2026-08-19-session-projection-mandatory-seam.md)); the loud runtime check and `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` are deleted with it. The session store keeps the explicit posture: an absent `ctx.get('sessions')` (a strict global read, never the caller-scope-bound property proxy) fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. apiproxy's dedicated `PROJECTIONS_UNAVAILABLE` wire face is deleted along with the code; `SESSION_STORE_UNAVAILABLE` goes through the generic internal fallback — apiproxy's composition injects `sessions` itself, so that error is unreachable in its deployment, and a dedicated mapping would violate the need principle. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency. +- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`, a required injection). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing; a null sentinel in the row does not count either — it falls to the third rung for the authoritative refold's verdict. A count/interval checkpoint inside the creation window can land a fork seed's replayed ancestor identity in the row — the ancestor's seq falls inside the seed range, the seq gate rejects it, and it likewise falls to the third rung's verdict. - Per-child isolation: a single child's failed cold full read only turns that row into an `unavailable` diagnostic, naturally retried on the next listing, without affecting siblings (see the four-state mapping). - The cold path's lifecycle witness: preparation's result must still point at the lifecycle that was enumerated — the witness field set is the same seven fields as the old SOURCE_CONFLICT check (version, id, createdAt, cwd, parentSession, seedLength, delegationDepth); a session deleted and republished under the same id degrades to a `corrupt` row in the old parent's catalog, leaking nothing of the new owner's child. - Cold-read concurrency is bounded by the constant 4 — it constrains a read-only scan of local media, not deployment behavior; when a networked persistence backend appears, it is promoted to a validated `Config` field. @@ -119,7 +122,7 @@ For each enumerated child, the ladder's result maps to a row through four states - `unsupported` is no longer produced: the type and the wire enum retain the member under "data structures stay as they are", and this note records it as no longer produced. - Descriptor-less settled debris moves from the old implementation's omit into the `corrupt` diagnostic — damaged, dead child sessions in the corpus are visible rather than silently vanishing, which is exactly the original motivation for keeping diagnostics. -- Any registered unit whose fold/schema throws on this child's log is likewise contained as that child's diagnostic row, reason `corrupt` — a deterministic data fault, aligned with the old implementation's `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` mapping semantics; live and cold are treated alike, isolation is per-child, and siblings and the listing itself are unaffected. It is orthogonal to "value absent + running → omit": the creation window means "no data yet", a fold throw means "the data is bad" — a poisoned running child also gets a `corrupt` row rather than an omit. +- Only the `subagent` unit is folded by the list — live via `stateOf`, cold via the unit's own fold — and that fold never throws: a corrupt or unrecognized-version payload folds to the null sentinel, which the four-state mapping turns into that child's `corrupt` row (a deterministic data fault, aligned with the old implementation's `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` mapping semantics); no other registered unit is folded by listing, so there is no per-child containment for foreign folds. Live and cold are treated alike, isolation is per-child, and siblings and the listing itself are unaffected. It is orthogonal to "value absent + running → omit": the creation window means "no data yet", a fold to nothing means "the data is bad" — a poisoned running child also gets a `corrupt` row rather than an omit. Known boundary deviations (deliberately accepted, recorded with this note): @@ -128,17 +131,17 @@ Known boundary deviations (deliberately accepted, recorded with this note): - A live/persisted header conflict: the old implementation made it per-child corrupt; enumeration now prefers live with no consistency check, the conflict goes unnoticed, and the live record forms the row. - A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart). - An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted. -- Rung 2's later-event window: a cache row lands right after the first own descriptor, the log then appends a second own descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint — from then on a cold listing's rung 2, admitted by the seq≥seedLength gate, keeps serving the row's old identity (the first own descriptor's value), diverging from the authoritative refold (last-wins, the second), and a rung-2 hit triggers no refold, so nothing notices. Three boundaries: ① the precondition is a second own descriptor on the same child, violating the establishing provider's append-exactly-once contract — corruption-class data, same family and source as the multi-descriptor deviation; ② it takes both "corruption + a crash missing every checkpoint (the two mandatory points, turn/end and disposal, and the count/interval throttle points all unmet)" at once; ③ a healthy child (exactly one own descriptor) is unaffected — what the seq gate admits is precisely the only true identity. Self-healing: any live run of that child (the turn/end mandatory checkpoint) or any moment that triggers cache.write overwrites the whole row with a fresh fold (whole-record replace), and rung 2 serves correctly from then on; the authoritative paths (the rung-3 refold, the live snapshot, the resume fold) are correct from the start, and the divergence exists only in listing reads while the child stays cold and the row is never rewritten. The mechanical fixes were not taken: gate reconciliation would need the log-end seq, unavailable to a zero-read cold path; a cache row carrying the revision is an opaque token, incomparable and a cross-domain schema change — filed as accepted under the "the cache is never authoritative" doctrine. +- Rung 2's later-event window: a cache row lands right after the first own descriptor, the log then appends a second own descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint — from then on a cold listing's rung 2, admitted by the seq≥seedLength gate, keeps serving the row's old identity (the first own descriptor's value), diverging from the authoritative refold (last-wins, the second), and a rung-2 hit triggers no refold, so nothing notices. Three boundaries: ① the precondition is a second own descriptor on the same child, violating the establishing provider's append-exactly-once contract — corruption-class data, same family and source as the multi-descriptor deviation; ② it takes both "corruption + a crash missing every checkpoint (the two mandatory points, turn/end and disposal, and the count/interval throttle points all unmet)" at once; ③ a healthy child (exactly one own descriptor) is unaffected — what the seq gate admits is precisely the only true identity. Self-healing: any live run of that child (the turn/end mandatory checkpoint) or any moment that triggers cache.write overwrites the whole row with a fresh fold (whole-record replace), and rung 2 serves correctly from then on; the authoritative paths (the rung-3 refold, the live `stateOf` read, the resume fold) are correct from the start, and the divergence exists only in listing reads while the child stays cold and the row is never rewritten. The mechanical fixes were not taken: gate reconciliation would need the log-end seq, unavailable to a zero-read cold path; a cache row carrying the revision is an opaque token, incomparable and a cross-domain schema change — filed as accepted under the "the cache is never authoritative" doctrine. -Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm). +Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin's load requirement changes — `sessionQuery` dropped from inject, `sessionProjections` added as a required injection). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm). ### Change footprint | Area | Files | Change | | --- | --- | --- | -| subagent | projection.ts, projection-types.ts, index.ts | New `subagent` unit and its registration | -| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` replaced by `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) | -| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin`; `subagents.history` shares `session.history`'s source — live from in-memory events and the registry's watermark snapshot, cold from `inspectServable` reading persistence directly with a detached fold, no query service, the SESSION_QUERY_* error arms retired with it | +| subagent | projection.ts, projection-types.ts, index.ts | New host-only `subagent` unit and its registration | +| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` deleted, and `sessionProjections` becomes a required injection (no projection error code remains); new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) | +| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin`; `subagents.history` shares `session.history`'s source — live from in-memory events and the registry's watermark snapshot, cold from `inspectServable` reading persistence directly with a detached fold, no query service, the SESSION_QUERY_* error arms and the dedicated `PROJECTIONS_UNAVAILABLE` wire face retired with it | | tool | tool-subagent-control/list-agents.ts | Load requirement narrowed (`sessionQuery` dropped from inject); model-visible schema, description, and rendering unchanged | | wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | Types, row shape, and diagnostic handling **unchanged**; api/subagents.ts only reworded the `history` JSDoc to the dual arm | | core/session, session-persistence, session-projection(-cache), session-query(-sqlite) | — | **Zero changes** | @@ -159,19 +162,19 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir **Values landed with query index preparation.** Projection values folded into session index rows during the sqlite backend's reconciliation rebuild, for zero log reads in the steady read state: the `projectionsFor` bulk read face, the invalidation reconciliation of row values stored against the `(key → stateVersion)` registration set, and the SCHEMA bump. Retired wholesale: the direction was backwards — query infrastructure was forced to learn domain vocabulary (projection columns, registration-set reconciliation) while the sole consumer, the subagent list, is satisfied by read-time computation; with consumers down to zero, this derived persistence has no reason to exist. `SESSION_QUERY_PROJECTIONS_UNAVAILABLE` was deleted along with the read face. -**Subagent hand-rolled parsing plus an in-process memo plus creation seeding.** To excise the session-query dependency, the subagent package would parse descriptor events itself, avoid repeated full reads with an in-process memo, and seed initial values at creation. Superseded by the shipped ladder: live goes through the `sessionProjections` watermark cache and cold through `registry.restore`, reusing the registry's single fold authority — no second copy of descriptor-interpretation logic appears, and no process-state cache or seeding ordering is introduced. +**Subagent hand-rolled parsing plus an in-process memo plus creation seeding.** To excise the session-query dependency, the subagent package would parse descriptor events itself, avoid repeated full reads with an in-process memo, and seed initial values at creation. Superseded by the shipped ladder: live goes through the `sessionProjections` watermark cache and cold through the registered unit's fold, reusing the registry's single fold authority — no second copy of descriptor-interpretation logic appears, and no process-state cache or seeding ordering is introduced. **DeepReadonly on the session-query output surface (a read-path overhaul experiment).** Make the public query outputs deeply readonly to pin immutable borrowing at the type level. Rejected on evidence: 3 TS2589 occurrences (excessively deep type instantiation) plus 17 sites of array-position contagion (consumers' array methods and spread sites forced to follow); deep immutability is guaranteed by core/session's runtime deep freeze, and that read-path overhaul is not part of this note. ## Verification -`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. Second-rung cases: an own-seq identity used directly with zero `inspect`, a fork seed's ancestor identity (seq inside the seed range) rejected by the gate and falling through, an in-row identity absence (null sentinel or absent key) falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold; cold-path lifecycle tampering degrades to `corrupt` field by witness field (`it.each` over the seven). The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the existing keyless snapshots (`subagent-list-agents` among others) are unchanged, pinning that the healthy path's wire and model-visible surfaces did not move; a new keyless snapshot, `subagent-diagnostic` (examples/headless-agent), pins the four-state mapping's diagnostic classification — the model-visible changes such as descriptor-less settled debris becoming a `corrupt` row. +`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; without the registry the service never activates (the mandatory seam — a `setup` variant asserting `ctx.get('subagents')` stays undefined); a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes (`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` for an absent store). Second-rung cases: an own-seq identity used directly with zero `inspect`, a fork seed's ancestor identity (seq inside the seed range) rejected by the gate and falling through, an in-row identity absence (null sentinel or absent key) falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold; cold-path lifecycle tampering degrades to `corrupt` field by witness field (`it.each` over the seven). The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the existing keyless snapshots (`subagent-list-agents` among others) are unchanged, pinning that the healthy path's wire and model-visible surfaces did not move; a new keyless snapshot, `subagent-diagnostic` (examples/headless-agent), pins the four-state mapping's diagnostic classification — the model-visible changes such as descriptor-less settled debris becoming a `corrupt` row. ## Consequences - Listing a live child reads zero log throughout; with the cache unmounted or missed, a cold child pays one full `inspect` read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache of its own is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it. -- The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, and loading the `list_agents` plugin no longer requires `sessionQuery`. -- Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read all use the registry's and the cache's existing reads (snapshot, cachedSnapshot, restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. +- The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, loading the `list_agents` plugin no longer requires `sessionQuery`, and `sessionProjections` becomes a required injection of `SubagentRuntime` — a deployment without the projection registry never activates the service (the mandatory seam). +- Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read all use the unit's own reads (live `stateOf`, the cache's `cachedSnapshot`, the cold unit fold), and no hand-written bypass fold exists; if some future consuming surface bypasses the unit with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. - Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration. - The diagnostic and enumeration semantics leaves six boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, an unknown parent yielding an empty list instead of not-found, and rung 2's later-event window); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data, the unknown-parent one is a silent query-semantics change, and the rung-2 window is a self-healing cache-serving divergence under the double condition of corruption plus a crash; resume authorization is unaffected throughout, all explicitly accepted. - Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise. @@ -179,6 +182,8 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir ## Related - [Durable subagent catalog and list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) — partially superseded by this note: the descriptor remains the durable authority for mode/label and the fold input, while the list's enumeration and value retrieval move to the subagent-owned merge plus the projection ladder. -- [Session projections and command lifecycle logging](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) — the authority for the registry contract; this note adds the `subagent` identity unit to it and becomes a consumer instance of the two existing reads, snapshot and restore. +- [Session projections and command lifecycle logging](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) — the authority for the registry contract; this note adds the `subagent` identity unit to it and consumes it through the live `stateOf` read and the cold unit fold. +- [Session projection state and client views](2026-08-19-session-projection-state-and-client-views.md) — the state/client split; the `subagent` identity unit is host-only state in the state table, and `subagentTiming` keeps the client wire view. +- [Session projections as a required seam](2026-08-19-session-projection-mandatory-seam.md) — `sessionProjections` becomes a required injection; the list's error contract follows it (registry absence is an activation-time failure, and the projection error code is deleted). - [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md) — the origin of `SessionHeader.origin` (#1569), the first half of taking identity determination off the log; its history cold read (inspect prefix plus registry fold) is the same-shape precedent for this note's value ladder. - [Reusable Session preparation before publication](2026-08-05-session-preparation.md) — the `inspect()` cold read and LRU reuse; the cold child's full-read cost model builds on it. diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index acd1628afc..9680a96310 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -14,25 +14,25 @@ Status: implemented ## 决策 -mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级「算完即止」阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到过 seq 门的身份即定值;否则一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、不自建缓存、无回写。 +mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级「算完即止」阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到过 seq 门的身份即定值;否则一次 `persistence.inspect` 整读加经注册的 `subagent` unit 折叠。无索引、不自建缓存、无回写。 消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。「值随查询索引落库」已整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被「算完即止」显式接受。前两条与退役理由详见考虑过的替代方案一节。 要点: - **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 -- **取值三级「算完即止」阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 且过 seq 门(`seq >= seedLength ?? 0`)的 `subagent` 身份即直接用;否则一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——不自建缓存、无回写、无索引。 -- **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。 +- **取值三级「算完即止」阶梯**:live child 读 `sessionProjections.stateOf(session, 'subagent')`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 且过 seq 门(`seq >= seedLength ?? 0`)的 `subagent` 身份即直接用;否则一次 `persistence.inspect` 整读加经注册的 `subagent` unit 折叠;再没有就没有——不自建缓存、无回写、无索引。 +- **`subagent` projection unit 是折叠规则唯一权威**:live `stateOf` 读取、cold unit 折叠、GUI history 的 detached 折叠全部运行同一份已注册 unit,不存在第二份描述符解释逻辑。 - **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。 与既有记录的关系: - 本记录取代 [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活约定不动。属部分取代,两记录保持交叉链接。 -- [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) 的 registry 约定(`ProjectionDefinition`、`snapshot`、`restore`)零改动,本记录只为其新增 `subagent` 身份 unit 一个注册项,并成为 snapshot(live)与 restore(cold)两处既有读法的又一消费实例——GUI history 的冷读已是同款。折叠规则只在 registry 注册一份;任何消费面都经 registry 计算,不存在第二份折叠逻辑。 +- [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) 是 registry 约定的权威,其后由 [state-and-client-views 记录](2026-08-19-session-projection-state-and-client-views.md)拆分为 host 状态与客户端视图;本记录只为其新增 `subagent` 身份 unit 一个注册项(host-only 状态),并经 live `stateOf` 读取与同一 unit 的 cold 折叠消费它——GUI history 的冷读仍是同款 detached-restore 形状。折叠规则只在 registry 注册一份;任何消费面都经这一份已注册 unit 计算,不存在第二份折叠逻辑。 ### `subagent` projection unit -挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`: +挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`——host-only 状态,`subagentTiming` 保留客户端 wire view: ```ts ignore-check export type SubagentIdentityProjection = @@ -40,15 +40,18 @@ export type SubagentIdentityProjection = | { mode: 'continuable'; label: string; seq: number } declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionMap { + interface SessionProjectionStateMap { subagent: SubagentIdentityProjection | null } + interface SessionProjectionMap { + subagentTiming: SubagentTimingProjection + } } ``` -- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果是**可序列化的 null 哨兵**——map 条目为 `SubagentIdentityProjection | null`,非可选、非 undefined/缺 key。理由:registry 的 onChanged 推送经 JSON 序列化,undefined 字段被 stringify 丢弃,客户端帧校验拒收,消费方存储的旧身份将永不更新;null 完好过帧,消费方以哨兵替换旧身份。判定纪律:消费面把 null 与 undefined(仅 JSON 边界丢 key 可产生)一律视为无值。「算出来没有」如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 +- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果是**可序列化的 null 哨兵**——map 条目为 `SubagentIdentityProjection | null`,非可选、非 undefined/缺 key。理由:unit 的状态以纯 JSON checkpoint 持久化,undefined 字段被 stringify 丢弃,读侧无从区分被丢弃的 key 与缺值——旧身份将原样存活;null 完好过 JSON,消费方以哨兵替换旧身份。判定纪律:消费面把 null 与 undefined(仅 JSON 边界丢 key 可产生)一律视为无值。「算出来没有」如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。 - label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;mode/label 判别与下文 child 行的强约定完全一致(行不携带 `seq`——它是投影内部的 own-suffix 证明)。 -- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。state 增 `seq` 使 unit `stateVersion` 升至 2,既存 checkpoint 行按 registry 约定版本失配失效、落权威重折。 +- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。unit 为 host-only——无客户端 wire view,其状态不进客户端 snapshot 与 `onChanged` 帧——且与其他 unit 一律检查点化(`persist` 选项已删除);`stateVersion` 现为 3:增 `seq` 升至 2,state/client 视图拆分把折叠状态改为直接的 `SubagentIdentityProjection | null` 哨兵后再升至 3。既存 checkpoint 行按 registry 约定版本失配失效、落权威重折。 - 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。 ### 枚举:subagent 自管 live-preferred 合并 @@ -68,12 +71,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' { | 级 | 读法 | 成本 | | --- | --- | --- | -| 1:live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 | +| 1:live child | `ctx.sessionProjections.stateOf(session, 'subagent')` | 零日志读——注册表既有水位缓存,同步取值 | | 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份且 `identity.seq >= header.seedLength ?? 0` 才直接用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 | -| 3:cold child,兜底 | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 | +| 3:cold child,兜底 | `persistence.inspect(id)` 整读 + 经注册的 `subagent` unit 折叠 | 每次列表一次整读现算 | -- 错误约定:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。会话存储同理:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。两码的 wire 映射有别:apiproxy 只为 `PROJECTIONS_UNAVAILABLE` 设专门 wire 脸,`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 -- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮约定相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决。 +- 错误约定:`sessionProjections` 是必需注入——`SubagentRuntime` 在 inject 集里声明它,没有 registry 的部署根本无法激活服务(与 loop),`listChildren` 不可达,而不是供出降级行([mandatory-seam 记录](2026-08-19-session-projection-mandatory-seam.md));响亮运行时检查与 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 随之删除。会话存储保留显式姿态:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。apiproxy 为 `PROJECTIONS_UNAVAILABLE` 设的专门 wire 脸随码删除;`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。 +- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的必需注入相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决。 - per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。 - 冷路径的生命周期见证:preparation 的结果必须仍指向枚举时的那个生命周期——见证字段集与旧 SOURCE_CONFLICT 检查同款七字段(version、id、createdAt、cwd、parentSession、seedLength、delegationDepth);同 id 删除后重新发布的会话对旧 parent 的目录降级为 `corrupt` 行,不外漏新 owner 的 child。 - 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。 @@ -119,7 +122,7 @@ export type SubagentListEntry = - `unsupported` 不再被产出:类型与 wire 枚举按「数据结构保持现状」留存该成员,本记录留档其为不再产出。 - descriptor-less 定局残骸从旧实现的 omit 归入 `corrupt` diagnostic——库里的坏、死子会话可见,不静默消失,这正是保留 diagnostic 的原始动机。 -- 任一注册 unit 的 fold/schema 在该 child 日志上抛错,同样收纳为该 child 的 diagnostic 行,reason `corrupt`——确定性数据故障,对齐旧实现 `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` 的映射语义;live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是「尚无数据」,fold 抛错是「数据坏了」——running 的中毒 child 也出 `corrupt` 行而非 omit。 +- 列表只折叠 `subagent` 一个 unit——live 经 `stateOf`,cold 经该 unit 自身的折叠——而该折叠永不抛错:描述符损坏或版本不认识折为 null 哨兵,由四态映射收纳为该 child 的 `corrupt` 行(确定性数据故障,对齐旧实现 `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` 的映射语义);列表不再折叠任何其他注册 unit,也就不存在对外来 fold 的逐 child 收纳。live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是「尚无数据」,折为无值是「数据坏了」——running 的中毒 child 也出 `corrupt` 行而非 omit。 已知边界偏差(有意接受,随本记录留档): @@ -128,17 +131,17 @@ export type SubagentListEntry = - live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 - 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 - 未知 parent,旧实现经 session-query 抛 not-found(「parent session … was not found」);现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。 -- rung 2 的更晚事件窗口:cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反建档提供方「恰追加一次」约定,属损坏类数据,与多描述符偏差同族同源;②需「损坏 + 崩溃错过 checkpoint(turn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)」双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live snapshot、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按「cache 永不为权威」总纲归档为接受项。 +- rung 2 的更晚事件窗口:cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反建档提供方「恰追加一次」约定,属损坏类数据,与多描述符偏差同族同源;②需「损坏 + 崩溃错过 checkpoint(turn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)」双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live `stateOf` 读取、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按「cache 永不为权威」总纲归档为接受项。 -消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。 +消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件的加载要求变化——inject 去掉 `sessionQuery`、新增必需注入 `sessionProjections`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。 ### 改动落点 | 区域 | 文件 | 改动 | | --- | --- | --- | -| subagent | projection.ts、projection-types.ts、index.ts | 新 `subagent` unit 与注册 | -| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) | -| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin`;`subagents.history` 与 `session.history` 同源——live 用内存事件与注册表水位快照,cold 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役 | +| subagent | projection.ts、projection-types.ts、index.ts | 新 host-only `subagent` unit 与注册 | +| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 删除,`sessionProjections` 转为必需注入(不再存在投影错误码);新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) | +| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin`;`subagents.history` 与 `session.history` 同源——live 用内存事件与注册表水位快照,cold 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂与 `PROJECTIONS_UNAVAILABLE` 专门 wire 脸随之退役 | | tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 | | wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | 类型、行形状与 diagnostic 处理**零改动**;api/subagents.ts 仅 `history` 的 JSDoc 措辞改为双臂 | | core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | — | **零改动** | @@ -159,19 +162,19 @@ export type SubagentListEntry = **值随 query 索引 preparation 落库。** 投影值在 sqlite backend 的对账重建里折叠落进 session 索引行,读稳态零日志:`projectionsFor` 批量读面、行值随 `(key → stateVersion)` 注册集存储的失效对账与 SCHEMA bump。整体退役:方向反了——查询基础设施被迫认识领域词汇(投影列、注册集对账),而唯一消费方 subagent 列表读时现算即可满足;消费方归零后,这套派生持久化没有存在理由。`SESSION_QUERY_PROJECTIONS_UNAVAILABLE` 随读面一并删除。 -**subagent 手工 parse 加进程 memo 加创建播种。** 为摘除 session-query 依赖,由 subagent 包自己解析描述符事件、以进程内 memo 避免重复整读、创建时播种初值。被已交付的阶梯取代:live 走 `sessionProjections` 水位缓存、cold 走 `registry.restore`,复用 registry 这一份折叠权威,不再出现第二份描述符解释逻辑,也不引入进程态缓存与播种时序。 +**subagent 手工 parse 加进程 memo 加创建播种。** 为摘除 session-query 依赖,由 subagent 包自己解析描述符事件、以进程内 memo 避免重复整读、创建时播种初值。被已交付的阶梯取代:live 走 `sessionProjections` 水位缓存、cold 走注册 unit 的折叠,复用 registry 这一份折叠权威,不再出现第二份描述符解释逻辑,也不引入进程态缓存与播种时序。 **session-query 输出面 DeepReadonly(读路径改造实验)。** 公开查询输出深只读化,以在类型层面钉死不可变借用。实证否决:3 处 TS2589(类型实例化过深)加 17 处数组位传染(消费方数组方法与展开处被迫跟改);深层不可变由 core/session 的运行时深冻结保证,该读路径改造未纳入本记录。 ## 验证 -`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本约定:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;提供方未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。第二级例:own-seq 身份直用零 `inspect`、fork 种子祖先身份(seq 落在 seed 区间)被门拒绝落底、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折;冷路径 lifecycle 篡改按见证七字段逐一(`it.each`)降级为 `corrupt`。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。 +`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本约定:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时服务根本不激活(mandatory seam——`setup` 变体断言 `ctx.get('subagents')` 保持 undefined);live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;提供方未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码(存储缺席时 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`)。第二级例:own-seq 身份直用零 `inspect`、fork 种子祖先身份(seq 落在 seed 区间)被门拒绝落底、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折;冷路径 lifecycle 篡改按见证七字段逐一(`it.each`)降级为 `corrupt`。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。 ## 后果 - live child 的列表全程零日志读;cold child 在 cache 未挂载或未命中时每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案「算完即止」,不自建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。 -- subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`。 -- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读走的都是 registry 与 cache 的既有读法(snapshot、cachedSnapshot、restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 +- subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`,而 `sessionProjections` 转为 `SubagentRuntime` 的必需注入——没有投影 registry 的部署根本不会激活服务(mandatory seam)。 +- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读走的都是该 unit 自身的读法(live `stateOf`、cache 的 `cachedSnapshot`、cold unit 折叠),不存在手写旁路折叠;若未来某消费面绕开该 unit 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 - per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。 - 诊断与枚举语义留下六处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表、rung 2 更晚事件窗口),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,未知 parent 一处是查询语义的静默变化,rung 2 窗口一处是损坏加崩溃双条件下可自愈的缓存供值分歧;恢复鉴权均不受影响,显式接受。 - pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。 @@ -179,6 +182,8 @@ export type SubagentListEntry = ## 相关 - [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 -- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)——registry 约定的权威;本记录为其新增 `subagent` 身份 unit,并成为 snapshot/restore 两处既有读法的消费实例。 +- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)——registry 约定的权威;本记录为其新增 `subagent` 身份 unit,并经 live `stateOf` 读取与 cold unit 折叠消费它。 +- [session projection 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.md)——state/client 拆分;`subagent` 身份 unit 是状态表中的 host-only 状态,`subagentTiming` 保留客户端 wire view。 +- [session projections 作为必需接缝](2026-08-19-session-projection-mandatory-seam.md)——`sessionProjections` 转为必需注入;列表的错误约定随其变化(registry 缺席是激活期失败,投影错误码删除)。 - [web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。 - [发布前可复用的 Session 准备阶段](2026-08-05-session-preparation.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 3cf7133534..404f863e75 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.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 .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 62c46c99a2283b03cf75d8823783367dd6b3473a -2026-07-06-sandbox.zh.md: 82c2e7962800c007a207f0204bf47cef01f79a36 +2026-07-06-sandbox.md: 491f103ebb59b2103d0140b3e1ad935d79018361 +2026-07-06-sandbox.zh.md: 0df77f595b82e0bb69f29aac21d4c46c21f7a47a diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 62c46c99a2..491f103ebb 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -89,10 +89,10 @@ Left open: what a durable grant's scope identity is beyond the sandbox mode — #### Per-session modes: the session log as the store ``` -effective(session) = findLast(the session's knob events)?.value ?? the composition-config default +effective(session) = sessionProjections.stateOf(session, '') ?? the composition-config default ``` -The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's log. Restart immunity and multi-session isolation follow from replay, with no external config store. The in-process subagent driver snapshots a parent's explicit override at delegation and seeds a source-tagged event after the child's optional fork prefix, so delegation cannot fall back to a wider default ([decision](2026-07-25-subagent-policy-inheritance.md)). +The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's log; the knob's projection unit folds it, and every unit's state is checkpointed, so restart immunity and multi-session isolation follow from the durable projection cache plus log replay, with no external config store. The in-process subagent driver snapshots a parent's explicit override at delegation and seeds a source-tagged event after the child's optional fork prefix, so delegation cannot fall back to a wider default ([decision](2026-07-25-subagent-policy-inheritance.md)). **One event per knob, owned by its domain** — the merge-extensible `SessionEventMap` idiom every existing event family already follows (`approval/*` in `dsh-user-approval`, `hook/*` in the hooks packages): @@ -103,7 +103,7 @@ interface SessionEventMap { } ``` -Each owner exports the same three-piece kit: the event declaration, a pure fold (`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)` — a `findLast`, typed to the domain's closed union), and THE write path (`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)` — a switch IS its event; nothing mutates state out of band). No shared owner service, no generic facts map, no registry: a third knob copies the ~40-line pattern into its own package. Execution follows the fold on both sides — the bash tool's per-call stamp reads it as the middle rung of the § Escalation precedence chain, and the approval seam's `'never'` gate is [the approval Agent Note](2026-07-06-approval-seam.md)'s side of the same pattern. +Each owner contributes the same pieces: the event declaration, one projection unit registered on the required `ctx.sessionProjections` registry (`key` `sandboxMode` / `approvalPolicy`, `stateVersion` 1, a zod `stateSchema` that validates persisted state, `init` to `null`, and `apply` — the fold, a find-last-shaped transition over its knob event typed to the domain's closed union), and THE write path (`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)` — a switch IS its event; nothing mutates state out of band). The registry drives every unit eagerly over committed session events and checkpoints all states uniformly — host-only units included, no `persist` opt-in — so restart immunity and multi-session isolation rest on the durable cache plus log replay rather than a per-package read helper. It is shared framework infrastructure, not a facts map with an owner service: each domain keeps its own event and unit, and a third knob writes its own event, unit, and setter in its own package and registers the unit — the drive, checkpointing, and read face come from the registry, and there is no fold export to copy. Host readers read the folded override through `stateOf(session, key)` (`SandboxPolicyService.overrideOf` and `ApprovalService.overrideOf` are those reads), and contributors and readers both require `sessionProjections` ([the mandatory projection seam](../architecture/2026-08-19-session-projection-mandatory-seam.md)). Execution follows the projected state on both sides — the bash tool's per-call stamp reads it as the middle rung of the § Escalation precedence chain, and the approval seam's `'never'` gate is [the approval Agent Note](2026-07-06-approval-seam.md)'s side of the same pattern. Before each proposed step, sandbox and approval policy are rendered as ordered contributions to one desired policy-context message. The listener reconciles that message against session history, the claimed batch, and its pending `next-step` inbox entry. Once claimed and entered, the loop records the complete sourced `user/message`; both `'ask'` and `'never'` are explicit, so neither owner needs switch narration or last-told state. @@ -165,7 +165,7 @@ What shipped pins — the tiers in Testing hold each: - A resumed session's overrides enter its first new policy-context message with no catch-up state; a composition default changed while the process was down likewise appears in that message. - Two concurrent sessions never see each other's state or notices. - Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd. -- Policy ownership stays in plugins through `SessionEventMap` merging, inbox mutation from `agent/pre-step`, and capability-owned resolution; the generic loop only claims and records the final entered batch. +- Policy ownership stays in plugins through `SessionEventMap` merging, the projection units registered on the required `ctx.sessionProjections` registry, inbox mutation from `agent/pre-step`, and capability-owned resolution; the generic loop only claims and records the final entered batch. Costs and accepted limits: @@ -192,7 +192,7 @@ Costs and accepted limits: - **Which tools actually run confined?** OS subprocesses through `ctx.shell` — the bash tools, and hook commands transitively — plus the filesystem tools (`read`/`write`/`edit`) through the sandboxed `ctx.fs` provider (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)): bash confines via the OS runner, fs via an in-process path fence, both keying off the same `ctx.sandboxPolicy` mode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary). - **Does a granted escalation persist?** No. The grant is consumed by the exact foreground or background call that asked; every neighboring call keeps its own effective mode. A later background denial surfaces through `job_output` and may ground a new exact-command retry. - **When does a runtime mode switch take effect?** Once its session event commits, the next pre-step policy-context reconciliation and the next capability resolution fold the new mode. The sourced context message records what the model was told, and any later denial names the same policy at the point of use. -- **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline enters the next full policy-context message. +- **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides restore from the durable projection cache and refold over the session log (`effective = projected override ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline enters the next full policy-context message. - **What does `enforcement: 'partial'` on a result mean?** The selected backend enforces the subset its kernel ABI governs — e.g. Landlock before ABI v3 does not govern path truncate — and says so structurally instead of refusing the host; the probe's report line distinguishes the cases. The bwrap and Seatbelt profiles govern every promised file effect by construction, so they always report `full`. ## Prior art @@ -202,5 +202,6 @@ In-repo precedents this design copies or contrasts with: - [The capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md) — the Service Definition / Service Provider / Consumer split and the "don't split preemptively" timing rule the second consumer satisfied. - The `dsh-shell` request/spec split ([the bash vocabulary catalog](../../../../docs/subsystems/shell.md)) — the complete `sandboxPolicy` rides its per-call carrier, and the explicit-`resolve()` defaulting convention. - [The approval seam Agent Note](2026-07-06-approval-seam.md) — the channel escalation asks through; its answerer waterfall, audit pair, and one-package rationale are recorded there. -- [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [standalone log-only events](../simplification/2026-07-28-remove-synthetic-log-only-turns.md) — the log-as-store foundation the per-session modes fold over, and the explicit durability boundary the anchoring design obeys. +- [Session projections as a required seam](../architecture/2026-08-19-session-projection-mandatory-seam.md) — the required `ctx.sessionProjections` registry the knob units register on; host readers read the folded override through `stateOf(session, key)`. +- [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [standalone log-only events](../simplification/2026-07-28-remove-synthetic-log-only-turns.md) — the log-as-store foundation the projection units fold over, and the explicit durability boundary the anchoring design obeys. - [The interception extension-points Agent Note](2026-06-30-interception-extension-points.md) — the `tools/pre-execute` vocabulary the escalation gate deliberately does not reuse (an escalating call has no pre-execute moment of its own). diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 82c2e79628..0df77f595b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -89,10 +89,10 @@ Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness #### 按会话模式:会话日志即存储 ``` -effective(session) = findLast(the session's knob events)?.value ?? the composition-config default +effective(session) = sessionProjections.stateOf(session, '') ?? the composition-config default ``` -默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中。重启后仍然有效以及多会话隔离均由回放自然保证,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.md))。 +默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中;该旋钮的投影单元折叠这条事件,且所有单元的状态都会被统一检查点,因此重启后仍然有效与多会话隔离来自持久化投影缓存加日志回放,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.md))。 **每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`): @@ -103,7 +103,7 @@ interface SessionEventMap { } ``` -每个拥有者导出相同的三件套:事件声明、纯 fold(`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)`——一个 `findLast`,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。无需共享的归属服务、通用 facts map 或注册表:第三个配置项只需将约 40 行模式复制到自己的包中。执行在两侧都遵循 fold——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.md) 同一模式的另一侧。 +每个拥有者贡献同一套构成:事件声明,一个注册在必需注入的 `ctx.sessionProjections` 注册表上的投影单元(`key` 为 `sandboxMode` / `approvalPolicy`,`stateVersion` 1,一个校验持久化状态的 zod `stateSchema`,`init` 为 `null`,以及 `apply`——即 fold,一个对其旋钮事件的 find-last 形态转移,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。注册表在每条已提交的会话事件上急切驱动所有单元,并统一检查点所有状态——host-only 单元也不例外,无 `persist` 开关——因此重启后仍然有效与多会话隔离依托持久化缓存加日志回放,而非每个包自带的读取辅助函数。它是共享的框架基础设施,而非带归属服务的 facts map:每个领域保留自己的事件与单元,第三个配置项只需在自己的包中写好事件、单元与 setter 并注册该单元——驱动、检查点与读取面都来自注册表,无需复制任何 fold 导出。host 读取方经 `stateOf(session, key)` 读取折叠后的覆盖(`SandboxPolicyService.overrideOf` 与 `ApprovalService.overrideOf` 就是这些读取),且贡献方与读取方都必需注入 `sessionProjections`([必需投影 seam](../architecture/2026-08-19-session-projection-mandatory-seam.md))。执行在两侧都遵循投影状态——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.md) 同一模式的另一侧。 每次拟议步骤之前,沙箱策略与批准策略都会渲染为一条目标策略上下文消息中的有序贡献。监听器将该消息与会话历史、已领取批次及其待处理的 `next-step` inbox 条目协调。消息一旦被领取并进入步骤,循环就会记录完整且带来源的 `user/message`;`'ask'` 与 `'never'` 都会明确写入,因此两个归属方都无需切换叙述或「上次告知」状态。 @@ -165,7 +165,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - 恢复会话的覆盖项会进入其首条新策略上下文消息,无需追赶状态;进程停止期间变更的组合默认值也会出现在该消息中。 - 两个并发会话永远看不到彼此的状态或通知。 - 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录;bash 和 fs 写入在调用方会话的 cwd 内成功,对其相邻会话的 cwd 则失败。 -- 策略归属仍通过 `SessionEventMap` 合并、从 `agent/pre-step` 变更 inbox,以及由能力归属方拥有的解析留在插件中;通用循环只领取并记录最终进入步骤的批次。 +- 策略归属仍通过 `SessionEventMap` 合并、注册在必需注入的 `ctx.sessionProjections` 注册表上的投影单元、从 `agent/pre-step` 变更 inbox,以及由能力归属方拥有的解析留在插件中;通用循环只领取并记录最终进入步骤的批次。 代价与已接受的限制: @@ -192,7 +192,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **哪些工具实际在约束下运行?** 通过 `ctx.shell` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。 - **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `job_output` 呈现,并且可以作为一次新的精确命令重试的依据。 - **运行时模式切换何时生效?** 一旦其会话事件提交,下一次 pre-step 策略上下文协调与下一次能力解析都会折叠新模式。带来源的上下文消息会记录模型收到的内容,之后的任何拒绝都会在使用点命名同一策略。 -- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从会话日志回放(`effective = fold ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值会进入下一条完整策略上下文消息。 +- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从持久化投影缓存恢复并在会话日志上重新折叠(`effective = 投影覆盖 ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值会进入下一条完整策略上下文消息。 - **结果上的 `enforcement: 'partial'` 是什么意思?** 所选后端强制其内核 ABI 管控的子集——例如 ABI v3 之前的 Landlock 不管控路径 truncate——并以结构化方式如此声明而非拒绝主机;探测的报告行区分各种情况。bwrap 和 Seatbelt profile 构造上管控所有承诺的文件操作,因此始终报告 `full`。 ## 先例 @@ -202,5 +202,6 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - [能力 seam Agent Note](../architecture/2026-06-13-capability-seams.md)——Service Definition/Service Provider/Consumer 拆分与「不要过早拆分」的时机规则(第二个消费方满足了该规则)。 - `dsh-shell` 的 request/spec 拆分([bash 词汇目录](../../../../docs/subsystems/shell.md))——完整的 `sandboxPolicy` 搭载其按调用载体,以及显式 `resolve()` 默认约定。 - [批准 seam Agent Note](2026-07-06-approval-seam.md)——升级请求通过的通道;其应答器 waterfall(瀑布式事件)、审计对和单包理由记录在那里。 -- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.md)与[独立纯日志事件](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)——按会话模式 fold 所依赖的日志即存储基础,以及锚定设计遵守的显式持久性边界。 +- [会话投影作为必需 seam](../architecture/2026-08-19-session-projection-mandatory-seam.md)——旋钮单元注册所在的必需 `ctx.sessionProjections` 注册表;host 读取方经 `stateOf(session, key)` 读取折叠后的覆盖。 +- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.md)与[独立纯日志事件](../simplification/2026-07-28-remove-synthetic-log-only-turns.md)——投影单元折叠所依赖的日志即存储基础,以及锚定设计遵守的显式持久性边界。 - [拦截扩展点 Agent Note](2026-06-30-interception-extension-points.md)——`tools/pre-execute` 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。 diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml index c42aed6d51..1317ce6d40 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.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 .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md -2026-07-14-cross-family-fs-sandbox.md: bd6284d47c0a8a5a4a58ec50f060eb12e2d9c75b -2026-07-14-cross-family-fs-sandbox.zh.md: a61949988e90d92dce904ffbb799e20be201a8c8 +2026-07-14-cross-family-fs-sandbox.md: 248e5017c40bc1a36b2d6491b0a859894916eac3 +2026-07-14-cross-family-fs-sandbox.zh.md: b2d59e1cbabe6dcf3aa8204496437a1c2d758f2a diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md index bd6284d47c..248e5017c4 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md @@ -21,8 +21,8 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching `packages/sandbox/sandbox-policy/` (`@deepseek-ai/dsh-sandbox-policy`) registers `ctx.sandboxPolicy`, the single owner of the deployment's sandbox policy: - `Config`: `mode` (the closed `SandboxMode` union, default `read-only`) and `workspaceRoot` (default the process cwd, resolved absolute). Misconfiguration fails loud at load. -- The per-session override event `sandbox/mode`, with its pure fold (`effectiveSandboxMode(events)`), its write path (`setSandboxMode(session, mode)`), and `SANDBOX_MODES`. The event is policy state — consumed by two families — so it lives here, not in either capability's seam. Its shape and log-only semantics match the `approval/*` precedent. -- `resolve({ session?, mode? })`, which returns a complete per-call `SandboxExecutionPolicy`: explicit approved mode > the session fold > `defaultMode`, and the session's immutable cwd > configured `workspaceRoot` fallback. +- The per-session override event `sandbox/mode`, folded by the `sandboxMode` projection unit this package registers on the required `ctx.sessionProjections` registry (`stateVersion` 1, host-only; `stateOf(session, 'sandboxMode')` is the host read), its write path (`setSandboxMode(session, mode)`), and `SANDBOX_MODES`. The event is policy state — consumed by two families — so it lives here, not in either capability's seam. Its shape and log-only semantics match the `approval/*` precedent, and contributors and readers both require `sessionProjections` ([the mandatory projection seam](../architecture/2026-08-19-session-projection-mandatory-seam.md)). +- `resolve({ session?, mode? })`, which returns a complete per-call `SandboxExecutionPolicy`: explicit approved mode > the session's projected override > `defaultMode`, and the session's immutable cwd > configured `workspaceRoot` fallback. - `defaultMode` / `workspaceRoot` accessors retained as deployment fallbacks and the capability-advertisement fact. `dsh-bash-sandbox` carries no sandbox config of its own — it injects `sandboxPolicy` and uses its deployment fallback only for direct calls. `dsh-tool-bash` and `dsh-tool-fs` pass the active session to `ctx.sandboxPolicy.resolve()`, so both receive the same effective mode and cwd root on every call; `dsh-permission-presets` presets and the ACP bridge write through the relocated setter. The seams that own bash and fs execution remain session-free — the session dependency lives in the policy package and tool consumers. @@ -78,7 +78,7 @@ What shipped — the tiers in § Testing hold each: - Under `read-only`, `write`/`edit` return the `[sandbox: file access denied under read-only mode]` marker and the disk is untouched; `read`/`listDir` behave identically to `dsh-fs-local`. - Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, a new file created under such a symlink, and alias-equivalent root spellings — denies every escape while admitting the same directory identity on real disks. - A denied fs mutation retried once with `sandbox_permissions` + `justification` prompts through the composed approval chain; a grant runs exactly that call under the wider mode and the write lands; rejected/cancelled/unavailable each produce their verbatim fail-closed text and mutate nothing. -- One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandbox/mode` fold. +- One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandboxMode` projection. - Concurrent sessions with different cwd roots carry different policies through the same service instances; neither family caches one session's root for the next call. - A direct `ctx.fs.writeText` with no per-call stamp is confined at the deployment default. - The escalation fields on `write`/`edit` exist exactly when the mounted `ctx.fs` confines, absent under `dsh-fs-local`. @@ -93,6 +93,6 @@ Costs and accepted limits: ## Testing -- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins deployment fallback, session mode/root resolution, explicit-mode precedence, the fold/setter, load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-policy fence and containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, root-ending-in-separator, and alias-equivalent spelling) on a real filesystem, plus per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, complete policy resolution, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission-presets` consume the same policy kit. +- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins deployment fallback, session mode/root resolution, explicit-mode precedence, the `sandboxMode` projection unit's fold and the setter, load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-policy fence and containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, root-ending-in-separator, and alias-equivalent spelling) on a real filesystem, plus per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, complete policy resolution, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission-presets` consume the same policy kit. - Keyless e2e: one real Cordis context creates two agents with different session cwd roots, runs the shipped bash and fs tools concurrently, and world-verifies that own-project writes land while both cross-project writes are denied. - Snapshot: the acp-agent example composes `dsh-sandbox-policy` + `dsh-fs-sandbox`; the pinned header carries the fs escalation fields and the `sandbox/mode` event name, re-recorded once. diff --git a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md index a61949988e..b2d59e1cba 100644 --- a/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.zh.md @@ -21,8 +21,8 @@ Status: implemented `packages/sandbox/sandbox-policy/`(`@deepseek-ai/dsh-sandbox-policy`)注册 `ctx.sandboxPolicy`,即部署沙箱策略的唯一所有者: - `Config`:`mode`(封闭的 `SandboxMode` 联合,默认 `read-only`)与 `workspaceRoot`(默认进程 cwd,解析为绝对路径)。配置错误会在加载时明确报错。 -- per-session 覆盖事件 `sandbox/mode`,连同它的纯折叠(`effectiveSandboxMode(events)`)、写入路径(`setSandboxMode(session, mode)`)与 `SANDBOX_MODES`。该事件是策略状态——被两个家族消费——所以它归于此处,而不归于任一能力的 seam。它的形状与仅日志(log-only)语义遵循 `approval/*` 的先例。 -- `resolve({ session?, mode? })` 返回完整的单次调用 `SandboxExecutionPolicy`:显式批准的模式 > 会话折叠结果 > `defaultMode`,而会话中不可变的 cwd > 配置的 `workspaceRoot` 回退值。 +- per-session 覆盖事件 `sandbox/mode`,由本包注册在必需注入的 `ctx.sessionProjections` 注册表上的 `sandboxMode` 投影单元折叠(`stateVersion` 1、host-only;`stateOf(session, 'sandboxMode')` 即 host 读取),连同它的写入路径(`setSandboxMode(session, mode)`)与 `SANDBOX_MODES`。该事件是策略状态——被两个家族消费——所以它归于此处,而不归于任一能力的 seam。它的形状与仅日志(log-only)语义遵循 `approval/*` 的先例,且贡献方与读取方都必需注入 `sessionProjections`([必需投影 seam](../architecture/2026-08-19-session-projection-mandatory-seam.md))。 +- `resolve({ session?, mode? })` 返回完整的单次调用 `SandboxExecutionPolicy`:显式批准的模式 > 会话的投影覆盖 > `defaultMode`,而会话中不可变的 cwd > 配置的 `workspaceRoot` 回退值。 - 保留 `defaultMode` / `workspaceRoot` 访问器,作为部署回退值与能力宣告依据。 `dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy`,仅在直接调用时使用其中的部署回退值。`dsh-tool-bash` 与 `dsh-tool-fs` 把当前会话传给 `ctx.sandboxPolicy.resolve()`,因此两者每次调用都会取得相同的生效模式与 cwd 根目录;`dsh-permission-presets` 预设与 ACP(Agent Client Protocol)bridge 经由迁移后的 setter 写入。拥有 bash 与 fs 执行的 seam 仍不依赖会话——会话依赖归策略包与工具消费方所有。 @@ -78,7 +78,7 @@ Status: implemented - 在 `read-only` 下,`write`/`edit` 返回 `[sandbox: file access denied under read-only mode]` 标记,磁盘不受触动;`read`/`listDir` 与 `dsh-fs-local` 行为一致。 - 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录、在这样一个符号链接下新建的文件,以及根路径的等价别名形式——在真实磁盘上拒绝每一种逃逸,同时允许文件系统认定为同一目录的路径。 - 一个被拒的 fs 变更,携带 `sandbox_permissions` + `justification` 重试一次,会经组合的审批链提示;一次授权让恰好那一次调用在更宽的模式下运行且写入落盘;rejected/cancelled/unavailable 各自产生其逐字的 fail-closed 文案且不做任何变更。 -- 一次 `permission` 预设切换同时管辖两个家族:会话切换模式后,下一次 bash 调用与下一次 fs 变更都从同一个 `sandbox/mode` 折叠遵循新模式。 +- 一次 `permission` 预设切换同时管辖两个家族:会话切换模式后,下一次 bash 调用与下一次 fs 变更都从同一个 `sandboxMode` 投影遵循新模式。 - cwd 根目录不同的并发会话通过同一组服务实例携带不同策略;两个家族都不会缓存某个会话的根目录供下一次调用使用。 - 一次无 per-call 盖章的直连 `ctx.fs.writeText` 会被围栏于部署默认值。 - `write`/`edit` 上的升级字段恰好在被挂载的 `ctx.fs` 受限时存在,在 `dsh-fs-local` 下不存在。 @@ -93,6 +93,6 @@ Status: implemented ## 测试 -- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住部署回退、会话模式/根目录解析、显式模式优先级、折叠/setter、加载期模式拒绝,以及 HMR(热模块替换)安全。`dsh-fs-sandbox` 在真实文件系统上钉住按策略执行的围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、以分隔符结尾的根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、完整策略解析、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission-presets` 使用同一套策略工具集。 +- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住部署回退、会话模式/根目录解析、显式模式优先级、`sandboxMode` 投影单元的折叠与 setter、加载期模式拒绝,以及 HMR(热模块替换)安全。`dsh-fs-sandbox` 在真实文件系统上钉住按策略执行的围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、以分隔符结尾的根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、完整策略解析、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission-presets` 使用同一套策略工具集。 - 无密钥 e2e:一个真实 Cordis 上下文创建两个 agent,其会话的 cwd 根目录各不相同;系统并发运行正式发布的 bash 与 fs 工具,再通过外部可观察结果验证各自在所属项目中的写入成功,而两次跨项目写入都被拒绝。 - 快照:acp-agent 示例组合 `dsh-sandbox-policy` + `dsh-fs-sandbox`;被钉住的 header 携带 fs 升级字段与 `sandbox/mode` 事件名,一次性重录。 diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index e1476a74c6..b765bccf9e 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.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 .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md -2026-07-22-durable-subagent-catalog-and-list-agents.md: 3fbb633bd3fecdd0b3bbfca0c6a318b91b5bc054 -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 05d82b4fa962b6357948903b3a7fa3bbbd49660f +2026-07-22-durable-subagent-catalog-and-list-agents.md: 3ddc2f83196513969307f94715f3626a6c45edde +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 7a40bdf27608747d9a231b674648da85c083528d diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index 3fbb633bd3..3ddc2f8319 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -35,7 +35,7 @@ Session lineage is broader than subagent identity: an ordinary `ctx.sessions.for The published logical record is also the activity source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `inactive`. Activity comes directly from the trace and causes no additional child-log load. `inactive` encodes neither successful completion nor resumability: it may describe settled one-shot history or a continuable child for which `send_message` can materialize another Activation. Conversely, `running` says only that the session is live: a live continuable Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects it as an ownership conflict. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or activity source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation. -The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to `SubagentRuntime`: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, `subagents`, and `sessionQuery` can list even when `agents` is absent. The method throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before loading the runtime or doing query work when the query service is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without installing or loading session query; the list-tool fiber remains inactive until the required service is available, while another direct service consumer receives the same explicit call-time contract. This dependency posture — the optional `sessionQuery`, its error code, and the list tool's load requirement — is part of the superseded read path: the current codes (`SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`) and the narrowed load requirement live in the [superseding note](../architecture/2026-08-06-subagent-list-identity-projection.md). +The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to `SubagentRuntime`: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, `subagents`, and `sessionQuery` can list even when `agents` is absent. The method throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before loading the runtime or doing query work when the query service is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without installing or loading session query; the list-tool fiber remains inactive until the required service is available, while another direct service consumer receives the same explicit call-time contract. This dependency posture — the optional `sessionQuery`, its error code, and the list tool's load requirement — is part of the superseded read path: the current listing contract lives in the [superseding note](../architecture/2026-08-06-subagent-list-identity-projection.md), where `sessionProjections` is a required injection of `SubagentRuntime` and `listChildren` reads it directly, so a missing projection registry fails at activation rather than at listing time, and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` (absent session store) remains the only stable availability error code. `listChildren(parentSessionId, signal?)` forwards the caller's signal to `traceSession()` and the conditional exact `readEvent()` operation. `listEvents()` has no cancellation parameter, so the listing path checks the signal before and after that await and after each candidate settles. If any query operation rejects after the signal aborts, the service normalizes the result to `SubagentError` with stable code `CANCELLED`; a backend abort error or a diagnostic-mapped query error cannot escape or become a successful partial listing. @@ -93,7 +93,7 @@ The first version has no child deletion operation. If later product behavior del ## Testing - `packages/subagent/subagent/tests/service.spec.ts` pins descriptor v2 parsing for both modes and proves an unlabeled raw start resolves a one-shot descriptor before provider dispatch. `packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts` proves the local driver appends that descriptor inside the initial turn, returns the published id when cancellation lands in the factory-to-run handoff, and keeps result and handle-disposal failures on separate channels. Delegation-tool tests pin propagation of their existing display description and preserve independent result and disposal diagnostics. -- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; loud `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; typed stable error codes; and descendant listing's iterative stable pre-order, traversal through ordinary and one-shot intermediates, positioned diagnostics, lifecycle revalidation, and cancellation. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface. +- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; non-activation without the projection registry (its mandatory injection seam) and a loud `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; typed stable error codes; and descendant listing's iterative stable pre-order, traversal through ordinary and one-shot intermediates, positioned diagnostics, lifecycle revalidation, and cancellation. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface. - `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (one optional `scope` enum), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, registry-derived child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, the descendants scope's pre-order parent/depth annotations across a live waiting branch, cancellation forwarding to both scopes, the no-agent rejection, the `agents` load requirement without `sessionQuery`, and HMR disposal. - The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) fences its second parent turn on a snapshot-only `subagent/end` marker, then executes `list_agents` for real against the subagent service, the projection registry, and JSONL persistence, rendering ` [ready] —