From 907c6334c112a976d7183734fc47c73bf6cb6856 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 19 Aug 2026 13:33:24 +0800 Subject: [PATCH 01/72] 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/72] 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/72] 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/72] 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/72] 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/72] 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/72] 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/72] 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] —

+ + {open + ? ( +
    + {rows.map((record) => { + const overdue = Date.parse(record.scheduledAt) <= now + return ( +
  • + + + {record.prompt} + + {formatScheduleFrequency(record, t)} + + {formatScheduleLocalTime(record.scheduledAt)} + + + {formatScheduleRelative(record.scheduledAt, now, t)} + + +
  • + ) + })} +
+ ) + : null} +
+ ) +} diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts new file mode 100644 index 0000000000..8ecb1f5bae --- /dev/null +++ b/packages/client/ui-schedule/src/client/index.ts @@ -0,0 +1,35 @@ +/** Browser half of the read-only Schedule catalog. */ + +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-schedule/client' +import { ScheduleCatalogAction } from './ScheduleCatalogAction.tsx' +import { en, NS, zh, type ScheduleCatalogKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Read-only active Schedule catalog copy. */ + 'schedule.catalog': ScheduleCatalogKey + } +} + +/** Required services for locale registration and header-slot contribution. */ +export const inject = ['slots', 'locale'] + +/** Register the dictionaries and Session-header catalog action. */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-schedule: dictionaries') + ctx.slots.inject( + 'conversation.session.header.actions', + () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'schedule-catalog', + // Static Session identity precedes this entry; background jobs follow it. + order: 10, + locale: NS, + }, ScheduleCatalogAction), + ) +} diff --git a/packages/client/ui-schedule/src/client/locales.ts b/packages/client/ui-schedule/src/client/locales.ts new file mode 100644 index 0000000000..b6cd678036 --- /dev/null +++ b/packages/client/ui-schedule/src/client/locales.ts @@ -0,0 +1,51 @@ +/** `schedule.catalog` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'schedule.catalog' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'trigger.one': '{count} 个提醒', + 'trigger.other': '{count} 个提醒', + 'list.aria': '活动提醒', + 'status.scheduled': '等待中', + 'status.overdue': '已逾期', + 'frequency.once': '单次', + 'frequency.every': '{value}{unit}一次', + 'unit.day.one': '天', + 'unit.day.other': '天', + 'unit.hour.one': '小时', + 'unit.hour.other': '小时', + 'unit.minute.one': '分钟', + 'unit.minute.other': '分钟', + 'unit.second.one': '秒', + 'unit.second.other': '秒', + 'relative.now': '现在到期', + 'relative.future': '{value}{unit}后', + 'relative.overdue': '已逾期 {value}{unit}', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'trigger.one': '{count} reminder', + 'trigger.other': '{count} reminders', + 'list.aria': 'Active reminders', + 'status.scheduled': 'Scheduled', + 'status.overdue': 'Overdue', + 'frequency.once': 'Once', + 'frequency.every': 'Every {value} {unit}', + 'unit.day.one': 'day', + 'unit.day.other': 'days', + 'unit.hour.one': 'hour', + 'unit.hour.other': 'hours', + 'unit.minute.one': 'minute', + 'unit.minute.other': 'minutes', + 'unit.second.one': 'second', + 'unit.second.other': 'seconds', + 'relative.now': 'Due now', + 'relative.future': 'in {value} {unit}', + 'relative.overdue': '{value} {unit} overdue', +} + +/** Key domain of the Schedule catalog namespace. */ +export type ScheduleCatalogKey = keyof typeof zh diff --git a/packages/client/ui-schedule/src/css-modules.d.ts b/packages/client/ui-schedule/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-schedule/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-schedule/src/index.ts b/packages/client/ui-schedule/src/index.ts new file mode 100644 index 0000000000..80b39b1bc9 --- /dev/null +++ b/packages/client/ui-schedule/src/index.ts @@ -0,0 +1,7 @@ +/** + * Read-only Schedule catalog plugin, node half. The empty apply keeps the + * optional browser feature addressable from the host-owned Loader overlay. + */ + +/** Host plugin body — Schedule catalog behavior exists only in the browser entry. */ +export function apply(): void {} diff --git a/packages/client/ui-schedule/src/invariant.ts b/packages/client/ui-schedule/src/invariant.ts new file mode 100644 index 0000000000..5df39278ac --- /dev/null +++ b/packages/client/ui-schedule/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion for the read-only Schedule catalog. */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-schedule' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-schedule-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: the package owns no mutable cross-plugin state. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts new file mode 100644 index 0000000000..d708b27d7b --- /dev/null +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -0,0 +1,101 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import * as ScheduleInvariant from '../src/invariant.ts' +import { en, NS, zh } from '../src/client/locales.ts' + +const Empty = () => null + +function headerEntryIds(ctx: Context): (string | undefined)[] { + return ctx.slots + .entries('conversation.session.header.actions') + .map(entry => entry.options.id) +} + +async function baseContext(): Promise { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + return ctx +} + +function declareHeader(ctx: Context): () => void { + return ctx.slots.register({ + name: 'root', + children: { + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, + } as never, Empty) +} + +describe('ui-schedule browser half', () => { + it('declares only the services used by registration', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('waits for the header declaration, orders between static context and Jobs, and tears down', async () => { + const ctx = await baseContext() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(headerEntryIds(ctx)).toEqual([]) + + const header = declareHeader(ctx) + ctx.slots.register({ + name: 'conversation.session.header.actions', id: 'agent-preset', order: -10, + }, Empty) + ctx.slots.register({ + name: 'conversation.session.header.actions', id: 'job-list', order: 20, + }, Empty) + expect(headerEntryIds(ctx)).toEqual(['agent-preset', 'schedule-catalog', 'job-list']) + + await fiber.dispose() + expect(headerEntryIds(ctx)).toEqual(['agent-preset', 'job-list']) + header() + await ctx.fiber.dispose() + }) + + it('registers both dictionaries and releases them with its fiber', async () => { + const ctx = await baseContext() + declareHeader(ctx) + ctx.locale.setLocale('zh') + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const translate = ctx.locale.bind(NS) + expect(translate('list.aria')).toBe(zh['list.aria']) + ctx.locale.setLocale('en') + expect(translate('list.aria')).toBe(en['list.aria']) + expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) + + await fiber.dispose() + expect(translate('list.aria')).not.toBe(en['list.aria']) + await ctx.fiber.dispose() + }) +}) + +describe('ui-schedule node and invariant halves', () => { + it('keeps the node half inert', () => { + expect(applyNode).not.toThrow() + }) + + it('reserves package ownership under its invariant companion name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + const fiber = ctx.plugin(ScheduleInvariant) + await fiber.await() + expect(ScheduleInvariant.name).toBe('client-ui-schedule-invariant') + expect(ScheduleInvariant.inject).toEqual(['invariants']) + expect(() => { + Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event']) + }).not.toThrow() + await fiber.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx new file mode 100644 index 0000000000..88e031cd06 --- /dev/null +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -0,0 +1,232 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionSnapshot, UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ScheduleRecord } from '@deepseek-ai/dsh-schedule/client' +import { ScheduleId } from '@deepseek-ai/dsh-schedule' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { + formatScheduleFrequency, + formatScheduleLocalTime, + formatScheduleRelative, + orderScheduleRecords, + ScheduleCatalogAction, + type ScheduleCatalogActionProps, +} from '../src/client/ScheduleCatalogAction.tsx' +import { en, zh } from '../src/client/locales.ts' + +const SESSION = 'schedule-session' as SessionId +const START = Date.parse('2026-08-25T12:00:00.000Z') + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(START) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +function record( + id: string, + kind: ScheduleRecord['kind'], + scheduledAt: number, + options: { prompt?: string; everySeconds?: number } = {}, +): ScheduleRecord { + const common = { + id: ScheduleId(id), + kind, + prompt: options.prompt ?? id, + scheduledAt: new Date(scheduledAt).toISOString(), + } + if (kind === 'after') return { ...common, kind, afterSeconds: 30 } + if (kind === 'every') return { ...common, kind, everySeconds: options.everySeconds ?? 300 } + return { ...common, kind } +} + +function sessionSnapshot(openState: SessionSnapshot['openState']): SessionSnapshot { + return { + sessionId: SESSION, + queue: [], + running: false, + subagent: null, + removed: false, + openState, + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, + } +} + +function props( + records: readonly ScheduleRecord[] | undefined, + openState: SessionSnapshot['openState'] = 'open', + dictionary: typeof zh | typeof en = en, +): ScheduleCatalogActionProps { + const snapshot = sessionSnapshot(openState) + const useSession = (select: (value: SessionSnapshot) => T): T => select(snapshot) + const useProjection = ((key: string, select?: (value: unknown) => unknown) => { + const value = key === 'schedule' ? records : undefined + return select === undefined ? value : select(value) + }) as UseProjection + return { + sessionId: SESSION, + useSession, + useProjection, + t: makeTranslate(dictionary), + } as unknown as ScheduleCatalogActionProps +} + +function prompts(): string[] { + return within(screen.getByRole('list', { name: en['list.aria'] })) + .getAllByRole('listitem') + .map(item => item.querySelector('[class*="prompt"]')?.textContent ?? '') +} + +describe('ScheduleCatalogAction visibility', () => { + it('renders only for a successfully opened Session with a non-empty projection', () => { + const active = [record('active', 'after', START + 60_000)] + const view = render() + expect(view.container.innerHTML).toBe('') + + view.rerender() + expect(view.container.innerHTML).toBe('') + for (const state of ['cold', 'loading', 'error'] as const) { + view.rerender() + expect(view.container.innerHTML).toBe('') + } + + view.rerender() + expect(screen.getByRole('button', { name: '1 reminder' })).toBeDefined() + }) + + it('closes and removes the trigger when the last live record disappears', () => { + const active = [record('active', 'after', START + 60_000)] + const view = render(<>) + const trigger = screen.getByRole('button', { name: '1 reminder' }) + fireEvent.click(trigger) + trigger.focus() + expect(screen.getByRole('list', { name: en['list.aria'] })).toBeDefined() + + view.rerender(<>) + expect(screen.queryByRole('button', { name: '1 reminder' })).toBeNull() + expect(document.activeElement).toBe(document.body) + expect(screen.getByRole('button', { name: 'Neighbor' })).not.toBe(document.activeElement) + }) +}) + +describe('ScheduleCatalogAction rows', () => { + it('shows only prompt and the three derived metadata fields, with overdue records first', () => { + const rawPrompt = ' Keep the complete long reminder prompt visible without truncation.' + const overdue = record('hidden-id', 'after', START - 60_000, { prompt: rawPrompt }) + const every = record('every-id', 'every', START + 300_000, { prompt: 'Check metrics', everySeconds: 300 }) + const at = record('at-id', 'at', START + 3_600_000, { prompt: 'Join meeting' }) + render() + fireEvent.click(screen.getByRole('button')) + + expect(prompts()).toEqual([rawPrompt, 'Check metrics', 'Join meeting']) + const rows = screen.getAllByRole('listitem') + expect(rows[0]?.getAttribute('data-overdue')).toBe('true') + expect(rows[1]?.getAttribute('data-overdue')).toBe('false') + expect(rows[0]?.querySelector('[data-schedule-status]')?.getAttribute('data-schedule-status')).toBe('overdue') + expect(rows[1]?.querySelector('[data-schedule-status]')?.getAttribute('data-schedule-status')).toBe('scheduled') + expect(rows[0]?.textContent).toContain('Overdue') + expect(rows[1]?.textContent).toContain('Scheduled') + expect(rows[0]?.textContent).toContain('Once') + expect(rows[0]?.textContent).toContain('1 minute overdue') + expect(rows[1]?.textContent).toContain('Every 5 minutes') + expect(rows[1]?.textContent).toContain('in 5 minutes') + expect(rows[2]?.textContent).toContain('Once') + expect(rows[2]?.textContent).toContain('in 1 hour') + expect(rows[2]?.textContent).toContain(formatScheduleLocalTime(at.scheduledAt)) + expect(document.querySelector('img')).toBeNull() + const text = screen.getByRole('list').textContent ?? '' + expect(text).not.toContain('hidden-id') + expect(text).not.toContain(overdue.scheduledAt) + expect(text).not.toMatch(/Delete|Retry|Details/) + expect(within(screen.getByRole('list')).queryAllByRole('button')).toHaveLength(0) + expect(rows.every(row => row.tabIndex === -1)).toBe(true) + }) + + it('renders exact recurring units without rounding and localizes both dictionaries', () => { + const tEn = makeTranslate(en) + const tZh = makeTranslate(zh) + const samples = [ + [86_400, 'Every 1 day', '1天一次'], + [172_800, 'Every 2 days', '2天一次'], + [3_600, 'Every 1 hour', '1小时一次'], + [7_200, 'Every 2 hours', '2小时一次'], + [300, 'Every 5 minutes', '5分钟一次'], + [301, 'Every 301 seconds', '301秒一次'], + ] as const + for (const [seconds, english, chinese] of samples) { + const item = record(String(seconds), 'every', START + 1_000, { everySeconds: seconds }) + expect(formatScheduleFrequency(item, tEn)).toBe(english) + expect(formatScheduleFrequency(item, tZh)).toBe(chinese) + } + expect(formatScheduleFrequency(record('once', 'at', START + 1_000), tZh)).toBe('单次') + expect(tZh('status.scheduled')).toBe('等待中') + expect(tZh('status.overdue')).toBe('已逾期') + }) + + it('derives relative seconds, minutes, hours, days, and the exact due boundary', () => { + const t = makeTranslate(en) + expect(formatScheduleRelative(new Date(START).toISOString(), START, t)).toBe('Due now') + expect(formatScheduleRelative(new Date(START + 500).toISOString(), START, t)).toBe('in 1 second') + expect(formatScheduleRelative(new Date(START + 61_000).toISOString(), START, t)).toBe('in 2 minutes') + expect(formatScheduleRelative(new Date(START - 3_600_000).toISOString(), START, t)).toBe('1 hour overdue') + expect(formatScheduleRelative(new Date(START - 172_800_000).toISOString(), START, t)).toBe('2 days overdue') + }) + + it('keeps equal targets stable and updates overdue status as the browser clock advances', () => { + const first = record('first', 'at', START + 500) + const second = record('second', 'at', START + 500) + expect(orderScheduleRecords([first, second], START).map(item => item.id)).toEqual(['first', 'second']) + expect(orderScheduleRecords([ + record('future', 'at', START + 1_000), + record('overdue', 'at', START - 1_000), + ], START).map(item => item.id)).toEqual(['overdue', 'future']) + + render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getAllByRole('listitem').every(row => row.getAttribute('data-overdue') === 'false')).toBe(true) + act(() => { vi.advanceTimersByTime(1_000) }) + expect(screen.getAllByRole('listitem').every(row => row.getAttribute('data-overdue') === 'true')).toBe(true) + }) +}) + +describe('ScheduleCatalogAction dismissal', () => { + const active = [record('active', 'after', START + 60_000)] + + it('closes on Escape, restores trigger focus, and ignores unrelated or closed keys', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.keyDown(trigger, { key: 'Escape' }) + fireEvent.click(trigger) + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(trigger) + }) + + it('toggles from the trigger and dismisses only on an outside pointer press', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.click(trigger) + fireEvent.pointerDown(screen.getByRole('list', { name: en['list.aria'] })) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + fireEvent.pointerDown(document.body) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(trigger) + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) +}) diff --git a/packages/client/ui-schedule/tsconfig.json b/packages/client/ui-schedule/tsconfig.json new file mode 100644 index 0000000000..4929733eb6 --- /dev/null +++ b/packages/client/ui-schedule/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../api/session-controller/tsconfig.client.json" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../schedule/schedule" + }, + { + "path": "../locale" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/client/ui-schedule/tsdown.config.ts b/packages/client/ui-schedule/tsdown.config.ts new file mode 100644 index 0000000000..78b3175a0e --- /dev/null +++ b/packages/client/ui-schedule/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-schedule', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e038b24a0d..f7cf47756d 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1143,6 +1143,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [ 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', 'client-ui-jobs JobListAction id \'job-list\'', + 'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 3d5d495cc0..39cacc6a2a 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1466,9 +1466,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'whole values per key with a usable row; empty when none.', }, { - signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', + signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, initialization: ProjectionInitialization, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', description: 'Cold read: fold every persisted unit over a stored log suffix, seeding each from its checkpoint row when usable — the one read recipe (cached state + forward tail replay + `view`) applied without a live `Session`. Call with the events returned by a persistence `readFrom(id, restoreFloor(checkpoint))` and that same floor as `baseSeq`; the floor\'s one-below anchor makes the supplied end honest, so a shrunk log is detected here. A row is usable iff its `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq` (`seq >= baseSeq - 1`), and it does not claim events past the supplied end (`seq <= endSeq`); an unusable row is discarded and its key refolds from `init` — which is only sound over the full log, so a discarded row with `baseSeq > 0` throws (the caller re-reads from seq 0, e.g. after a crash-repair truncation shrank the log below a row\'s watermark).', - parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }], + parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }, { name: 'initialization', description: 'normalized facts from the stored header returned by the same read.' }], returns: 'the snapshot cut at the supplied log end (`asOfSeq` is the last supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the refreshed checkpoint rows at that cut, ready for a durable write-back.', }, ], @@ -4183,7 +4183,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionDefinition', - 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}', + declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(initialization: ProjectionInitialization): 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: 'ProjectionInitialization', + declaration: 'export interface ProjectionInitialization {\n readonly seedLength: number;\n}', }, { name: 'ProjectionSnapshot', diff --git a/packages/schedule/README.i18n.yaml b/packages/schedule/README.i18n.yaml index 90aa318ed0..e43ce6a28f 100644 --- a/packages/schedule/README.i18n.yaml +++ b/packages/schedule/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/schedule/README.md -README.md: 2fac190cc19f40e5d5e87acacaddce970e4b8474 -README.zh.md: af4cd96dcb5f095a7ea83556e5f73eca532d0dee +README.md: 67d54465a5a32c7264f1624e697b068b0a421d8a +README.zh.md: cc662272a3ad488e19b1bcfd6657ced09b3975c0 diff --git a/packages/schedule/README.md b/packages/schedule/README.md index 2fac190cc1..67d54465a5 100644 --- a/packages/schedule/README.md +++ b/packages/schedule/README.md @@ -2,12 +2,12 @@ English | [中文](README.zh.md) -The Schedule family owns reminders whose durable state lives in the original Session log. A process-local owner waits only while that Session has a live root Agent; cold Sessions resume overdue work when they become live again and never imply an external notification channel. +The Schedule family owns reminders whose durable state lives in the original Session log. A process-local owner waits only while that Session has a live root Agent; cold Sessions resume overdue work when they become live again and never imply an external notification channel. An optional Session projection publishes the complete active-record set for read-only clients without changing that delivery boundary. | Package | Role | ctx key | |---|---|---| -| `schedule/` | Versioned Schedule events and fold, model-facing create/list/delete tools, and a live root-Agent timer owner | — | +| `schedule/` | Versioned Schedule events and fold, the active-record Session projection, model-facing create/list/delete tools, and a live root-Agent timer owner | — | -The package deliberately exposes no public Schedule service or mutable database. Tools and runtime append to the Session stream; due work enters the same conversation through the Agent's ordinary follow-up queue. +The package deliberately exposes no public Schedule service or mutable database. Tools and runtime append to the Session stream; due work enters the same conversation through the Agent's ordinary follow-up queue. The browser presentation is owned separately by [`dsh-client-ui-schedule`](../client/ui-schedule/README.md), whose catalog is current state rather than a delivery receipt. See [Session-local Schedule](../../docs/subsystems/schedule.md) for the durable record, transition, view, and delivery contracts. diff --git a/packages/schedule/README.zh.md b/packages/schedule/README.zh.md index af4cd96dcb..cc662272a3 100644 --- a/packages/schedule/README.zh.md +++ b/packages/schedule/README.zh.md @@ -2,12 +2,12 @@ [English](README.md) | 中文 -Schedule 家族负责管理提醒,其持久状态保存在原 Session 日志中。进程内 owner 只会在该 Session 拥有 live 根 Agent 时等待;cold Session 再次 live 后会恢复逾期工作,但这不意味着存在外部通知渠道。 +Schedule 家族负责管理提醒,其持久状态保存在原 Session 日志中。进程内 owner 只会在该 Session 拥有 live 根 Agent 时等待;cold Session 再次 live 后会恢复逾期工作,但这不意味着存在外部通知渠道。可选的 Session projection 会向只读客户端发布完整活动记录集合,而不改变该交付边界。 | 包 | 职责 | ctx 键 | |---|---|---| -| `schedule/` | 版本化 Schedule 事件与 fold、面向模型的创建/列出/删除工具,以及 live 根 Agent timer owner | 无 | +| `schedule/` | 版本化 Schedule 事件与 fold、活动记录 Session projection、面向模型的创建/列出/删除工具,以及 live 根 Agent timer owner | 无 | -本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件;到期工作通过 Agent 的普通 follow-up 队列进入同一对话。 +本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件;到期工作通过 Agent 的普通 follow-up 队列进入同一对话。浏览器呈现由 [`dsh-client-ui-schedule`](../client/ui-schedule/README.zh.md) 单独拥有;其目录表示当前状态,而非交付回执。 有关持久记录、转换、视图与交付约定,请参阅[仅限 Session 内的 Schedule](../../docs/subsystems/schedule.zh.md)。 diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 8dc406becf..2a97d193d9 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/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/schedule/schedule/README.md -README.md: f56c689198b7e17a85f43a1ecb6e5f1527da6a91 -README.zh.md: 7180565370d83a7207c73aa9a2539219974dace0 +README.md: ed5917ec3d6cbf617fe18bcc6898dec6cc417a32 +README.zh.md: bda4e4d4861cebcc7c836a2bcf6e760c92789c88 diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index f56c689198..ed5917ec3d 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -10,13 +10,21 @@ Load this function plugin after `ctx.sessions`, `ctx.agents`, `ctx.tools`, `ctx. Time-context is not a Schedule dependency. A composition may mount `@deepseek-ai/dsh-time-context` so the model can interpret natural language in the browser's request-local zone, as the official Schedule Web overlay does. The model must still pass an explicit offset or `time_zone` to `schedule_create`; Schedule never imports or infers from model context. +Session projection is optional. When `ctx.sessionProjections` exists, the plugin registers the strict `schedule` unit and exposes the complete active `ScheduleRecord[]`; a headless composition without the registry keeps the same tools and runtime. The browser-safe record vocabulary is available from the type-only `@deepseek-ai/dsh-schedule/client` export. The shipped Web bundle resolves the `ui-schedule` client package through a disabled row, and the explicit Schedule overlay enables that row alongside the Host Schedule services. + Every operation that reads or decides from the Schedule fold first awaits `ctx.sessions.flush(session)`. A missing, rejected, or detached persistence path returns `persistence_uncertain`; it never turns an unconfirmed live suffix into a list or not-found answer. A successful create or actual delete also awaits a post-append barrier before confirming the mutation. ## Durable state The package owns the strict version-1 `schedule/change` create, delete, and dispatch union. Every create record contains a stable Session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`. An `after` record also stores `afterSeconds`; an `at` record stores no copy of its submitted offset, local calendar fields, or interpreting zone; an `every` record stores `everySeconds` and treats `scheduledAt` as the earliest creation-anchor-aligned occurrence not yet dispatched. Delete and one-shot dispatch carry only the id. Every dispatch adds `acceptedAt`, from which replay advances directly to the first anchor-aligned target after that decision time. -Replay rejects unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. Normal Sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The package's `./invariant` companion applies the same policy to existing logs and candidate events. +Replay rejects unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. Normal Sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The Schedule projection receives the same normalized `seedLength` when its state is initialized and ignores the inherited prefix while using the same strict transition function. The package's `./invariant` companion applies the same policy to existing logs and candidate events. + +## Client projection + +The optional `schedule` Session projection persists `{ seedLength, active, seenIds }` as strict plain JSON and publishes only the complete `active` array. Its checkpoint schema reuses the durable Schedule decoder, rejects duplicate or inconsistent ids, and fails the existing Session open or read path on corrupt durable events instead of publishing a partial catalog. Live lazy build, event-driven build, cached restore, history reads, and detached Subagent reads all receive `seedLength` from the same Session header that supplied their events. + +The projection carries durable records only. It does not persist or transmit `scheduled` versus `overdue`, localized text, relative time, browser-local time, sorting state, open state, or delivery receipts. [`dsh-client-ui-schedule`](../../client/ui-schedule/README.md) derives those presentation values from the full array and the viewing browser's clock. ## Absolute-time input @@ -40,7 +48,7 @@ The live owner derives the earliest target from the durable fold. It splits wait An overdue reminder first checkpoints persistence. If a turn or another maintenance task owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. A successful maintenance task refolds, samples one decision time, builds the appropriate fixed framing, synchronously queues `followup()`, and appends dispatch before releasing the phase. A one-shot appends its id. Each Every record in a batch appends its id plus the same `acceptedAt`; integer arithmetic selects that record's latest due creation-anchor-aligned occurrence and advances it directly to the first future target. Missed intervals are never enumerated or replayed, distinct overdue records each contribute one occurrence, and there is no shared recurrence gate. Waking input remains parked until release, after which the owner checkpoints dispatch. -The follow-up opens a normal later turn after the Agent becomes fully idle; it never steers or interrupts the current conversation. Its assistant output appears through the ordinary transcript, with no independent receipt or Schedule-specific browser UI. Dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer. +The follow-up opens a normal later turn after the Agent becomes fully idle; it never steers or interrupts the current conversation. Its assistant output appears through the ordinary transcript, with no independent receipt. The optional Web catalog shows only currently active records and never represents dispatch success. Dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer. Framing or synchronous follow-up failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves dispatch pending for a later ordinary preflight. Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits without deleting durable records. @@ -115,3 +123,4 @@ The batch appends after existing history and preserves its reusable prefix. Its - **Latest-only catch-up** — an overdue Every record contributes only its latest due occurrence, so Schedule never replays a missed backlog. - **Narrow crash duplicate window** — a crash after synchronous follow-up admission but before the dispatch checkpoint can repeat the reminder; the package does not claim model completion, user acknowledgement, or exactly-once effects. - **Load-order boundary** — the plugin does not scan or adopt Agents that were already live when it loaded. +- **Catalog is read-only current state** — the optional Web surface has no history, mutation, retry, or acknowledgement semantics; terminal records disappear and delivery remains ordinary conversation output. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 7180565370..bda4e4d486 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -10,13 +10,21 @@ Time-context 不是 Schedule 的依赖。组合可以挂载 `@deepseek-ai/dsh-time-context`,使模型能够按浏览器的请求本地时区解释自然语言;官方 Schedule Web overlay 正是如此。模型仍必须向 `schedule_create` 传入显式偏移量或 `time_zone`;Schedule 绝不会从模型上下文中导入或推断该值。 +Session projection 是可选能力。`ctx.sessionProjections` 存在时,插件会注册严格的 `schedule` 单元并公开完整的活动 `ScheduleRecord[]`;不带注册表的 headless 组合仍保留相同工具与 runtime。浏览器安全的记录词汇由纯类型出口 `@deepseek-ai/dsh-schedule/client` 提供。shipped Web bundle 通过默认 disabled 的 row 解析 `ui-schedule` client 包,显式 Schedule overlay 再与 Host Schedule 服务一起启用该 row。 + 每项从 Schedule 折叠结果读取或作出判断的操作,都会先等待 `ctx.sessions.flush(session)`。持久化路径缺失、拒绝或已分离时,操作返回 `persistence_uncertain`;它绝不会把未经确认的 live 后缀当成列表或未找到结果。成功创建或实际删除后,还会等待追加后的持久化 barrier(屏障)再确认变更。 ## 持久状态 此包拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的提示词,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。`after` 记录还会存储 `afterSeconds`;`at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区;`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早一个创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id。Every dispatch 还会添加 `acceptedAt`;回放会据此直接推进到该决策时点之后的第一个锚点对齐目标。 -回放会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的一次性或 Every dispatch,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套模块会对现有日志和候选事件应用相同策略。 +回放会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的一次性或 Every dispatch,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。Schedule projection 初始化时接收同一个已规范化的 `seedLength`,忽略继承前缀,并复用同一严格 transition 函数。此包的 `./invariant` 配套模块会对现有日志和候选事件应用相同策略。 + +## 客户端 projection + +可选的 `schedule` Session projection 以严格纯 JSON 持久化 `{ seedLength, active, seenIds }`,只发布完整的 `active` 数组。其 checkpoint schema 复用持久 Schedule decoder,拒绝重复或内部不一致的 id;损坏的持久事件会使既有 Session 打开或读取路径失败,而不会发布部分目录。live 惰性构建、事件驱动构建、缓存恢复、history 读取与 detached Subagent 读取,都从提供对应事件的同一个 Session header 接收 `seedLength`。 + +projection 只携带持久记录,不持久化或传输 `scheduled`/`overdue`、本地化文案、相对时间、浏览器本地时间、排序状态、开合状态或交付回执。[`dsh-client-ui-schedule`](../../client/ui-schedule/README.zh.md) 从完整数组与查看方浏览器时钟派生这些呈现值。 ## 绝对时间输入 @@ -40,7 +48,7 @@ live owner 从持久折叠结果派生最早的目标。它会拆分超过 Node overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。获准执行的 maintenance task 会重新折叠、采样一个决策时点、构造相应的固定 framing、同步将 `followup()` 入队,并在释放 phase 前追加 dispatch。一次性提醒只追加 id。批次中的每条 Every 记录都会追加其 id 和相同的 `acceptedAt`;整数运算会选择该记录最新一个已到期且与创建锚点对齐的发生时点,并将记录直接推进到第一个未来目标。系统绝不会枚举或回放错过的间隔;每条不同的逾期记录各贡献一个发生时点,并且不存在共享的周期性准入门控。触发唤醒的 input 会保持 parked,直到 phase 释放;随后 owner 为 dispatch 建立检查点。 -Agent 完全 idle 后,follow-up 会开启一个普通的后续轮次;它绝不会中途引导或中断当前对话。assistant 输出通过普通 transcript(文本记录)显示,不存在独立回执或 Schedule 专属浏览器 UI。dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答。 +Agent 完全 idle 后,follow-up 会开启一个普通的后续轮次;它绝不会中途引导或中断当前对话。assistant 输出通过普通 transcript(文本记录)显示,不存在独立回执。可选 Web 目录只显示当前活动记录,绝不表示 dispatch 成功。dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答。 framing 构造或同步 follow-up 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight。agent 或插件执行资源释放时,会取消 timer、停止新工作,并等待进行中的 preflight 与 idle wait,且不会删除持久记录。 @@ -115,3 +123,4 @@ reminders_json: - **只追赶最新一次**:逾期 Every 记录只贡献其最新一个到期发生时点,因此 Schedule 绝不会回放因错过间隔而形成的积压。 - **存在狭窄的崩溃重复窗口**:同步 follow-up 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒重复;此包不承诺模型完成、用户确认或副作用恰好执行一次。 - **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 Agent。 +- **目录只是只读当前状态**:可选 Web 界面没有历史、mutation、Retry 或 acknowledgement 语义;终结记录会消失,交付仍然是普通对话输出。 diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 9cf315d544..e5a6fce780 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -22,12 +22,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "MIT", @@ -38,9 +43,13 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", @@ -52,6 +61,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/schedule/schedule/src/client.ts b/packages/schedule/schedule/src/client.ts new file mode 100644 index 0000000000..798a3cd14c --- /dev/null +++ b/packages/schedule/schedule/src/client.ts @@ -0,0 +1,2 @@ +/** Browser-safe Schedule vocabulary. @module @deepseek-ai/dsh-schedule/client */ +export type * from './types.ts' diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index 92e0db624b..b8e3c92e06 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -566,6 +566,57 @@ function dispatchedRecord(record: ScheduleRecord, change: DecodedDispatch): Sche : Object.freeze({ ...record, scheduledAt: occurrence.nextScheduledAt }) } +/** + * Apply one already-decoded Schedule change to a complete fold value. + * + * This is the single transition authority shared by full-log replay and the + * incremental Session projection. Inputs are never mutated; unchanged event + * filtering remains the caller's responsibility. + * @param folded - complete active records and used-id history before the change. + * @param change - one strictly decoded durable mutation. + * @returns the complete fold value after the mutation. + */ +export function applyScheduleChange( + folded: FoldedSchedules, + change: ScheduleChange, +): FoldedSchedules { + const active = new Map(folded.active.map(record => [record.id, record])) + const seen = new Set(folded.seenIds) + switch (change.operation) { + case 'create': + if (seen.has(change.schedule.id)) { + throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`) + } + seen.add(change.schedule.id) + active.set(change.schedule.id, change.schedule) + break + case 'delete': + if (!active.delete(change.id)) { + throw new ScheduleLogError(`schedule delete targets inactive id ${JSON.stringify(change.id)}`) + } + break + case 'dispatch': { + const record = active.get(change.id) + if (record === undefined) { + throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(change.id)}`) + } + const next = dispatchedRecord(record, change) + if (next === undefined) active.delete(change.id) + else active.set(change.id, next) + break + } + /* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */ + default: { + const unreachable: never = change + throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`) + } + } + return Object.freeze({ + active: Object.freeze([...active.values()]), + seenIds: Object.freeze([...seen]), + }) +} + /** * Fold the package-owned stream after the durable fork seed boundary. * @param events - Complete ordered session log or candidate-extended log. @@ -579,45 +630,15 @@ export function foldScheduleEvents( if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) { throw new ScheduleLogError('schedule seedLength must be within the supplied event log') } - const active = new Map() - const seen = new Set() + let folded: FoldedSchedules = Object.freeze({ + active: Object.freeze([]), + seenIds: Object.freeze([]), + }) for (const event of events.slice(seedLength)) { if (event.type !== 'schedule/change') continue - const change = decodeScheduleChange(event.data) - switch (change.operation) { - case 'create': - if (seen.has(change.schedule.id)) { - throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`) - } - seen.add(change.schedule.id) - active.set(change.schedule.id, change.schedule) - break - case 'delete': - if (!active.delete(change.id)) { - throw new ScheduleLogError(`schedule delete targets inactive id ${JSON.stringify(change.id)}`) - } - break - case 'dispatch': { - const record = active.get(change.id) - if (record === undefined) { - throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(change.id)}`) - } - const next = dispatchedRecord(record, change) - if (next === undefined) active.delete(change.id) - else active.set(change.id, next) - break - } - /* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */ - default: { - const unreachable: never = change - throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`) - } - } + folded = applyScheduleChange(folded, decodeScheduleChange(event.data)) } - return Object.freeze({ - active: Object.freeze([...active.values()]), - seenIds: Object.freeze([...seen]), - }) + return folded } /** diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 8a619e2e73..6dfbaeb4f6 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -6,6 +6,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-persistence' +// Type-only: resolves ctx.sessionProjections for the optional projection child. +import type {} from '@deepseek-ai/dsh-session-projection' +import { scheduleProjectionDefinition } from './projection.ts' import { ScheduleRuntime } from './runtime.ts' import { registerScheduleTools } from './tools.ts' @@ -38,6 +41,10 @@ type OwnerCleanup = () => void | Promise /** Install Schedule only for root agents published after this plugin loads. */ export function apply(ctx: Context): void { + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register(scheduleProjectionDefinition) + }) + const runtimes = new Map() let stopping = false diff --git a/packages/schedule/schedule/src/projection.ts b/packages/schedule/schedule/src/projection.ts new file mode 100644 index 0000000000..d7c77190fa --- /dev/null +++ b/packages/schedule/schedule/src/projection.ts @@ -0,0 +1,89 @@ +/** + * Strict Session projection of the Schedule domain's active reminder set. + * @module @deepseek-ai/dsh-schedule/projection + */ + +import { z } from 'zod' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { applyScheduleChange, decodeScheduleChange } from './domain.ts' +import type { FoldedSchedules } from './domain.ts' +import type { ScheduleChange, ScheduleId, ScheduleRecord } from './types.ts' + +/** Persisted projection state: the immutable fork boundary plus the complete Schedule fold. */ +export interface ScheduleProjectionState extends FoldedSchedules { + readonly seedLength: number +} + +const scheduleId = z.unknown().transform((value, context): ScheduleId => { + try { + const change = decodeScheduleChange({ version: 1, operation: 'delete', id: value }) as Extract< + ScheduleChange, + { operation: 'delete' } + > + return change.id + } catch { + context.addIssue({ code: 'custom', message: 'invalid Schedule id' }) + return z.NEVER + } +}) + +const scheduleRecord = z.unknown().transform((value, context): ScheduleRecord => { + try { + const change = decodeScheduleChange({ version: 1, operation: 'create', schedule: value }) as Extract< + ScheduleChange, + { operation: 'create' } + > + return change.schedule + } catch { + context.addIssue({ code: 'custom', message: 'invalid Schedule record' }) + return z.NEVER + } +}) + +const scheduleRecords = z.array(scheduleRecord) as unknown as z.ZodType + +const scheduleProjectionStateSchema = z.object({ + seedLength: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + active: scheduleRecords, + seenIds: z.array(scheduleId), +}).strict().superRefine((state, context) => { + const seen = new Set(state.seenIds) + if (seen.size !== state.seenIds.length) { + context.addIssue({ code: 'custom', message: 'seen Schedule ids must be unique' }) + } + const active = new Set() + for (const record of state.active) { + if (!seen.has(record.id)) { + context.addIssue({ code: 'custom', message: 'every active Schedule id must have been seen' }) + } + if (active.has(record.id)) { + context.addIssue({ code: 'custom', message: 'active Schedule ids must be unique' }) + } + active.add(record.id) + } +}) as unknown as z.ZodType + +/** Projection definition sharing the Schedule domain's strict transition authority. */ +export const scheduleProjectionDefinition = { + key: 'schedule', + stateSchema: scheduleProjectionStateSchema, + init: ({ seedLength }) => ({ seedLength, active: [], seenIds: [] }), + apply: (state, event) => { + if (event.seq < state.seedLength || event.type !== 'schedule/change') return state + return { + seedLength: state.seedLength, + ...applyScheduleChange(state, decodeScheduleChange(event.data)), + } + }, + wire: { + viewSchema: scheduleRecords, + view: state => state.active, + }, + stateVersion: 1, +} satisfies ProjectionDefinition<'schedule', ScheduleProjectionState> + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + schedule: ScheduleProjectionState + } +} diff --git a/packages/schedule/schedule/src/types.ts b/packages/schedule/schedule/src/types.ts index 24240172cc..cbe3972d32 100644 --- a/packages/schedule/schedule/src/types.ts +++ b/packages/schedule/schedule/src/types.ts @@ -219,3 +219,10 @@ declare module '@deepseek-ai/dsh-session/types' { 'schedule/change': ScheduleChange } } + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Complete active reminders owned by this Session's post-fork suffix. */ + schedule: readonly ScheduleRecord[] + } +} diff --git a/packages/schedule/schedule/tests/projection.spec.ts b/packages/schedule/schedule/tests/projection.spec.ts new file mode 100644 index 0000000000..253260a74a --- /dev/null +++ b/packages/schedule/schedule/tests/projection.spec.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { apply as applySchedule } from '../src/index.ts' +import { foldScheduleEvents, ScheduleId, ScheduleLogError } from '../src/domain.ts' +import { scheduleProjectionDefinition, type ScheduleProjectionState } from '../src/projection.ts' +import type { ScheduleRecord } from '../src/types.ts' + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +function afterRecord(id: string, prompt = id): ScheduleRecord { + return { + id: ScheduleId(id), + kind: 'after', + prompt, + afterSeconds: 30, + scheduledAt: '2026-08-25T12:00:00.000Z', + } +} + +function atRecord(id: string): ScheduleRecord { + return { + id: ScheduleId(id), + kind: 'at', + prompt: id, + scheduledAt: '2026-08-25T13:00:00.000Z', + } +} + +function everyRecord(id: string): ScheduleRecord { + return { + id: ScheduleId(id), + kind: 'every', + prompt: id, + everySeconds: 300, + scheduledAt: '2026-08-25T14:00:00.000Z', + } +} + +function change(data: unknown, seq: number): SessionEvent { + return { type: 'schedule/change', seq, time: seq, data } as SessionEvent +} + +function created(record: ScheduleRecord, seq: number): SessionEvent { + return change({ version: 1, operation: 'create', schedule: record }, seq) +} + +describe('Schedule Session projection', () => { + it('shares strict transitions with full replay and excludes the inherited fork prefix', () => { + const events: SessionEvent[] = [ + created(afterRecord('parent'), 0), + created(atRecord('child-at'), 1), + created(everyRecord('child-every'), 2), + change({ + version: 1, + operation: 'dispatch', + id: 'child-every', + acceptedAt: '2026-08-25T14:02:00.000Z', + }, 3), + { type: 'turn/start', seq: 4, time: 4, data: { turn: 1 } }, + ] + let projected: ScheduleProjectionState = scheduleProjectionDefinition.init({ seedLength: 1 }) + for (const event of events) projected = scheduleProjectionDefinition.apply(projected, event) + + expect(projected).toEqual({ seedLength: 1, ...foldScheduleEvents(events, 1) }) + expect(scheduleProjectionDefinition.wire.view(projected)).toEqual(projected.active) + expect(projected.active.map(record => record.id)).toEqual(['child-at', 'child-every']) + }) + + it('restores checkpoints, folds a bounded tail, and fails loud on damaged durable data', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(scheduleProjectionDefinition) + + const first = created(afterRecord('one'), 0) + const second = created(atRecord('two'), 1) + const initial = ctx.sessionProjections.restore({}, [first, second], 0, { seedLength: 0 }) + expect(initial.snapshot.values.schedule?.map(record => record.id)).toEqual(['one', 'two']) + + const removed = change({ version: 1, operation: 'delete', id: 'one' }, 2) + const resumed = ctx.sessionProjections.restore( + initial.checkpoint, + [second, removed], + 1, + { seedLength: 0 }, + ) + expect(resumed.snapshot.values.schedule?.map(record => record.id)).toEqual(['two']) + expect(resumed.checkpoint.schedule).toMatchObject({ ver: 1, seq: 2 }) + + expect(() => ctx.sessionProjections.restore( + {}, + [change({ version: 1, operation: 'delete', id: 'missing' }, 0)], + 0, + { seedLength: 0 }, + )).toThrow(ScheduleLogError) + }) + + it('rejects malformed or internally inconsistent checkpoint states', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(scheduleProjectionDefinition) + const row = (val: unknown) => ({ schedule: { ver: 1, seq: 0, val } }) + + expect(ctx.sessionProjections.viewCheckpoint(row({ + seedLength: 0, + active: [{ ...afterRecord('bad-time'), scheduledAt: 'not-an-instant' }], + seenIds: ['bad-time'], + }))).toEqual({}) + expect(ctx.sessionProjections.viewCheckpoint(row({ + seedLength: 0, + active: [afterRecord('missing')], + seenIds: [], + }))).toEqual({}) + expect(ctx.sessionProjections.viewCheckpoint(row({ + seedLength: 0, + active: [afterRecord('duplicate'), afterRecord('duplicate')], + seenIds: ['duplicate', 'duplicate'], + }))).toEqual({}) + expect(ctx.sessionProjections.viewCheckpoint(row({ + seedLength: 0, + active: [], + seenIds: [' bad-id'], + }))).toEqual({}) + }) + + it('registers only while the Schedule plugin fiber is live', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const fiber = ctx.plugin({ apply: applySchedule }) + await fiber.await() + + const session = ctx.sessions.create() + session.append('schedule/change', { + version: 1, + operation: 'create', + schedule: afterRecord('live'), + }) + expect(ctx.sessionProjections.snapshot(session).values.schedule).toHaveLength(1) + + await fiber.dispose() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + }) +}) diff --git a/packages/schedule/schedule/tsconfig.json b/packages/schedule/schedule/tsconfig.json index 3b7e107408..8e32dec9db 100644 --- a/packages/schedule/schedule/tsconfig.json +++ b/packages/schedule/schedule/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../session/session-persistence-jsonl" }, + { + "path": "../../session/session-projection" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index f7bdb62a3a..ee05ff64ad 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -183,13 +183,17 @@ export class SessionProjectionCache extends Service { const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta)) try { if (!related) throw new Error('unrelated log identity') - restored = this.ctx.sessionProjections.restore(cached, tail.events, floor) + restored = this.ctx.sessionProjections.restore(cached, tail.events, floor, { + seedLength: tail.meta.seedLength ?? 0, + }) } catch { // Recoverable failures are an unrelated record, a row outside the // supplied suffix or log end, and stateSchema rejection. The full read // removes every checkpoint seed and lets each unit refold from init. const whole = await persistence.readFrom(id, 0, signal) - restored = this.ctx.sessionProjections.restore({}, whole.events, 0) + restored = this.ctx.sessionProjections.restore({}, whole.events, 0, { + seedLength: whole.meta.seedLength ?? 0, + }) } await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back') return restored.snapshot diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 89154ec108..f3f28fdf0a 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -52,12 +52,17 @@ const marksUnit = (stateVersion = 1) => ({ }) satisfies ProjectionDefinition<'cache-test/marks', MarksState> /** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */ -function fakePersistence(logs: Map) { +function fakePersistence(logs: Map, seedLength?: number) { const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => { const events = logs.get(String(id)) if (events === undefined) throw new Error(`session "${id}" not found`) return { - meta: { version: 0, id, createdAt: 0 }, + meta: { + version: 0, + id, + createdAt: 0, + ...seedLength === undefined ? {} : { seedLength }, + }, events: events.filter(event => event.seq >= fromSeq), } }) @@ -73,6 +78,7 @@ interface HarnessOptions { config?: { writeEveryEvents: number; writeIntervalMs: number } stateVersion?: number logs?: Map + seedLength?: number } const contexts: Context[] = [] @@ -90,7 +96,7 @@ async function harness(options: HarnessOptions = {}) { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) ctx.sessionProjections.register(marksUnit(options.stateVersion)) - const persistence = fakePersistence(logs) + const persistence = fakePersistence(logs, options.seedLength) ctx.provide('sessionPersistence', persistence as never) const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return { ctx, pool, logs, fiber, persistence, cache: ctx.sessionProjectionCache } @@ -305,6 +311,19 @@ describe('SessionProjectionCache cold read', () => { expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('malformed'), 0, undefined) }) + it('passes the stored seed boundary through bounded and fallback full restores', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['seeded', storedLog([['real']])]]) + seedRow(pool, 'seeded', { ver: 1, seq: 1, val: { marks: 'not-an-array' } }) + const { ctx, cache } = await harness({ pool, logs, seedLength: 2 }) + const restore = vi.spyOn(ctx.sessionProjections, 'restore') + + await cache.coldSnapshot(SessionId('seeded')) + + expect(restore).toHaveBeenNthCalledWith(1, expect.any(Object), expect.any(Array), 1, { seedLength: 2 }) + expect(restore).toHaveBeenNthCalledWith(2, {}, expect.any(Array), 0, { seedLength: 2 }) + }) + it('write-back failure is contained: the snapshot is still served', async () => { const pool = new MemoryMediaPool() const logs = new Map([['soft', storedLog([['a']])]]) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 7ce339474e..54ad5af88e 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: 3b7ccecb7040b5340cd24da45d99bbfdc13fa15c -README.zh.md: bdf691761a6debf8f904924ab7198a5aed093b02 +README.md: 9a4687c1ac612a81966536e8590625e11889183f +README.zh.md: b79ea28a29e43bc474f372c4392636d24c0c9568 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 3b7ccecb70..9a4687c1ac 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -17,13 +17,15 @@ 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?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only. +- `ProjectionInitialization` — immutable Session facts supplied at every fresh fold boundary; it currently contains normalized `seedLength` so a domain can exclude an inherited fork prefix without reading ambient Session state. +- `ProjectionDefinition` — `{ key, stateSchema, init(initialization), apply(state, event), wire?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only. ## Contract -- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch. +- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, calls `init({ seedLength })` and folds the in-memory log on first touch. - **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). +- **Deterministic fold, complete wire value.** A unit synchronously validates and folds the Session events its domain owns; those durable events may be complete values or domain transitions. When a `wire` view exists, it always publishes the complete current value rather than a client-side delta. +- **Initialization follows the event source.** Live lazy and event-driven builds normalize `session.header.seedLength ?? 0`. Detached restore callers pass the same normalized value from the header returned with the persisted event read. The initialization object is immutable input to `init`; a unit must not fetch Session or process state behind the registry. - **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. 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. @@ -45,6 +47,6 @@ None; projections never assemble or send provider requests. - **Every tail page carries every client-visible key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large. - **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by ANY agent preset appears in every session's snapshot, including sessions whose own composition mounts nothing that produces it. A client must read the VALUE (`plan.active`, an empty todo list) rather than treat an absent key as absence of the feature; a unit whose empty value is indistinguishable from a real one belongs on the host plane instead, which is why `dsh-token-meter` sits there. -- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change. +- **Eager drive touches every unit per event** — cheap by construction (deterministic synchronous folds and the same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change. - **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. - **Synchronous unit discipline is only partially mechanical** — `wire.viewSchema.parse` rejects a Promise-returning view, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index bdf691761a..b79ea28a29 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -17,13 +17,15 @@ - `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?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。 +- `ProjectionInitialization`——每个新折叠边界都会收到的不可变 Session 事实;当前包含规范化后的 `seedLength`,使领域无需读取环境 Session 状态即可排除 fork 继承前缀。 +- `ProjectionDefinition`——`{ key, stateSchema, init(initialization), apply(state, event), wire?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。 ## 约定 -- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都在首次触达时从 `init` 出发在内存日志上折叠。 +- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都会在首次触达时调用 `init({ seedLength })` 并折叠内存日志。 - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。 -- **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 +- **确定性 fold,完整 wire 值。** 单元同步校验并折叠其领域拥有的 Session 事件;这些持久事件既可以是完整值,也可以是领域 transition。存在 `wire` 视图时,它始终发布完整当前值,而不是让客户端处理 delta。 +- **初始化跟随事件来源。** live 惰性构建与事件驱动构建会规范化 `session.header.seedLength ?? 0`。detached restore 调用方从与持久事件同一次读取返回的 header 传入同样的规范值。初始化对象是 `init` 的不可变输入;单元不得绕过注册表读取 Session 或进程状态。 - **单元的同步纪律。**`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 一视同仁。 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 @@ -45,6 +47,6 @@ - **每个尾页携带每个 client-visible key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 - **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——只要**任何**一个 agent preset 注册了某个 key,它就出现在每个会话的快照里,包括自身组装完全不产出该值的会话。客户端必须读**值**(`plan.active`、空的 todo 列表),不能把 key 缺席当作功能缺席;如果某个单元的空值与真实值无法区分,它就该待在宿主平面——`dsh-token-meter` 正因如此留在那里。 -- **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。 +- **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(确定性同步 fold、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 - **单元同步纪律只有部分可机械把关**——`wire.viewSchema.parse` 能拒绝返回 Promise 的 view,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套项记载了为何不存在运行时检查。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 89d356df02..44d8dbd1c3 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -10,9 +10,9 @@ * (capability-seam three-way split). Design authority: the session-projection * RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). * - * 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 - * unit's transition trivially cheap and every served value self-describing. + * Fold rule (load-bearing): a unit synchronously and deterministically + * validates and folds the Session events its domain owns. The client-facing + * wire value, when present, is always the complete current value. * * @module @deepseek-ai/dsh-session-projection */ @@ -31,6 +31,12 @@ import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts export type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' +/** Minimal immutable Session fact supplied when a projection state is initialized. */ +export interface ProjectionInitialization { + /** Number of inherited leading events that belong to a fork's source Session. */ + readonly seedLength: number +} + /** * One domain's state-driven computation unit: a pure synchronous fold plus * declarations and an optional client view — never an opaque getter. The framework drives @@ -49,9 +55,10 @@ export interface ProjectionDefinition< stateSchema: ZodType /** * State for the empty log. + * @param initialization - immutable Session facts needed to establish the fold boundary. * @returns the initial state. */ - init(): NoInfer + init(initialization: ProjectionInitialization): NoInfer /** * Pure transition: previous state + one committed event → next state. A * unit uninterested in an event MUST return the same state reference — an @@ -129,7 +136,7 @@ export type ProjectionCheckpoint = Record interface ErasedDefinition { key: string stateSchema: { parse(value: unknown): unknown } - init(): unknown + init(initialization: ProjectionInitialization): unknown apply(state: unknown, event: SessionEvent): unknown wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined stateVersion: number @@ -230,7 +237,7 @@ export class SessionProjectionRegistry extends Service { const erased: ErasedDefinition = { key: definition.key, stateSchema: definition.stateSchema, - init: () => definition.init(), + init: initialization => definition.init(initialization), apply: (state, event) => definition.apply(state as S, event), wire: wire === undefined ? undefined @@ -413,6 +420,7 @@ export class SessionProjectionRegistry extends Service { * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param events - the stored events with `seq >= baseSeq`, in seq order. * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). + * @param initialization - normalized facts from the stored header returned by the same read. * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last * supplied event's seq, `baseSeq - 1` for an empty tail) plus the * refreshed checkpoint rows at that cut, ready for a durable write-back. @@ -421,6 +429,7 @@ export class SessionProjectionRegistry extends Service { checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, + initialization: ProjectionInitialization, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } { const endSeq = events.at(-1)?.seq ?? baseSeq - 1 @@ -439,7 +448,7 @@ export class SessionProjectionRegistry extends Service { + 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0', ) } - let state = usable ? def.stateSchema.parse(row.val) : def.init() + let state = usable ? def.stateSchema.parse(row.val) : def.init(initialization) const from = usable ? row.seq : baseSeq - 1 for (const event of events) { if (event.seq > from) state = def.apply(state, event) @@ -454,8 +463,12 @@ export class SessionProjectionRegistry extends Service { } /** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */ - private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell { - let state = def.init() + private buildCell( + def: ErasedDefinition, + events: readonly SessionEvent[], + initialization: ProjectionInitialization, + ): UnitCell { + let state = def.init(initialization) for (const event of events) state = def.apply(state, event) return { state, observedSeq: (events.at(-1)?.seq ?? -1) } } @@ -464,7 +477,9 @@ export class SessionProjectionRegistry extends Service { private cellFor(registration: Registration, session: Session): UnitCell { let cell = registration.cells.get(session) if (cell === undefined) { - cell = this.buildCell(registration.def, session.events) + cell = this.buildCell(registration.def, session.events, { + seedLength: session.header.seedLength ?? 0, + }) registration.cells.set(session, cell) } return cell @@ -477,7 +492,9 @@ export class SessionProjectionRegistry extends Service { if (cell === undefined) { // Late build mid-stream: fold history before this event (seq = log // index, so the prefix slice is exact), then take the normal gate. - cell = this.buildCell(registration.def, session.events.slice(0, event.seq)) + cell = this.buildCell(registration.def, session.events.slice(0, event.seq), { + seedLength: session.header.seedLength ?? 0, + }) registration.cells.set(session, cell) } const next = registration.def.apply(cell.state, event) diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 3ee2491d4f..59bbcae8d5 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -19,6 +19,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { 'test/marks': MarksState 'test/count': number + 'test/seed': number } interface SessionProjectionMap { @@ -33,6 +34,7 @@ declare module '@deepseek-ai/dsh-session/types' { } type MarksState = { marks: string[] } | null +const INITIALIZATION = { seedLength: 0 } as const /** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */ const marksUnit = (): Omit, 'wire'> & { wire: NonNullable['wire']> } => ({ @@ -56,6 +58,15 @@ const countUnit = (): ProjectionDefinition<'test/count', number> => ({ stateVersion: 1, }) +/** Host-only sentinel proving which Session seed boundary initialized a cell. */ +const seedUnit = (): ProjectionDefinition<'test/seed', number> => ({ + key: 'test/seed', + stateSchema: z.number().int().nonnegative(), + init: initialization => initialization.seedLength, + apply: state => state, + stateVersion: 1, +}) + async function harness(): Promise<{ ctx: Context; session: Session }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -95,6 +106,30 @@ describe('SessionProjectionRegistry drive', () => { expect(snapshot.values['test/marks']).toEqual({ marks: [] }) }) + it('passes the normalized fork boundary to lazy, event-driven, and restore initialization', async () => { + const { ctx } = await harness() + const parentMark: SessionEvent = { + type: 'test/mark', seq: 0, time: 0, data: { marks: ['parent'] }, + } + + const lazy = ctx.sessions.create(undefined, { + seed: [parentMark], + meta: { seedLength: 1 }, + }) + ctx.sessionProjections.register(seedUnit()) + expect(ctx.sessionProjections.stateOf(lazy, 'test/seed')).toBe(1) + + const driven = ctx.sessions.create(undefined, { + seed: [parentMark], + meta: { seedLength: 1 }, + }) + mark(driven, ['child']) + expect(ctx.sessionProjections.stateOf(driven, 'test/seed')).toBe(1) + + const restored = ctx.sessionProjections.restore({}, [], 0, { seedLength: 7 }) + expect(restored.checkpoint['test/seed']).toEqual({ ver: 1, seq: -1, val: 7 }) + }) + it('notifies onChanged with the validated view and the causing seq, and skips same-reference applies', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register(marksUnit()) @@ -280,7 +315,7 @@ describe('SessionProjectionRegistry drive', () => { expect(() => ctx.sessionProjections.restore({ 'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } }, 'test/count': { ver: 99, seq: 2, val: 3 }, - }, tail, 3)).toThrow(/re-read from seq 0/) + }, tail, 3, INITIALIZATION)).toThrow(/re-read from seq 0/) // The full-log re-read (baseSeq 0) refolds the mismatched key from init. const full: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, @@ -291,7 +326,7 @@ describe('SessionProjectionRegistry drive', () => { const { snapshot, checkpoint } = ctx.sessionProjections.restore({ 'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } }, 'test/count': { ver: 99, seq: 2, val: 3 }, - }, full, 0) + }, full, 0, INITIALIZATION) expect(snapshot.asOfSeq).toBe(4) expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] }) expect('test/count' in snapshot.values).toBe(false) @@ -312,7 +347,7 @@ describe('SessionProjectionRegistry drive', () => { { type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } }, { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, ] - const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, tail, 3) + const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, tail, 3, INITIALIZATION) expect(snapshot.asOfSeq).toBe(4) // marks already covers the tail (watermark 4): nothing re-applied. expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] }) @@ -324,7 +359,7 @@ describe('SessionProjectionRegistry drive', () => { const { snapshot: current, checkpoint: currentCheckpoint } = ctx.sessionProjections.restore({ 'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } }, 'test/count': { ver: 1, seq: 4, val: 5 }, - }, [], 5) + }, [], 5, INITIALIZATION) expect(current.asOfSeq).toBe(4) expect('test/count' in current.values).toBe(false) expect(currentCheckpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 }) @@ -355,7 +390,7 @@ describe('SessionProjectionRegistry drive', () => { 'test/marks': { marks: ['stored'] }, }) - const restored = ctx.sessionProjections.restore(rows, [], 5) + const restored = ctx.sessionProjections.restore(rows, [], 5, INITIALIZATION) expect(restored.snapshot.values).toEqual({ 'test/marks': { marks: ['stored'] }, }) @@ -370,7 +405,7 @@ describe('SessionProjectionRegistry drive', () => { } expect(ctx.sessionProjections.viewCheckpoint(drifted)).toEqual({}) - expect(() => ctx.sessionProjections.restore(drifted, [], 3)).toThrow() + expect(() => ctx.sessionProjections.restore(drifted, [], 3, INITIALIZATION)).toThrow() }) it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { @@ -383,18 +418,18 @@ describe('SessionProjectionRegistry drive', () => { expect(floor).toBe(9) // …an intact log serves the anchor event and the checkpoint stands as-is. const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } } - const anchored = ctx.sessionProjections.restore(rows, [anchor], 9) + const anchored = ctx.sessionProjections.restore(rows, [anchor], 9, INITIALIZATION) expect(anchored.snapshot.values).toEqual({}) expect(anchored.checkpoint['test/count']).toEqual({ ver: 1, seq: 9, val: 10 }) // …while a log crash-repaired down to fewer events returns an empty tail: // the row overreaches the proven end and a tail read cannot fix this key. - expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/) + expect(() => ctx.sessionProjections.restore(rows, [], 9, INITIALIZATION)).toThrow(/re-read from seq 0/) // The full re-read discards the overreaching row and refolds from init. const events: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }, { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, ] - const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, events, 0) + const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, events, 0, INITIALIZATION) expect(snapshot.asOfSeq).toBe(1) expect(snapshot.values).toEqual({}) expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 1, val: 2 }) diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 95c3be8520..b9a016968c 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -395,7 +395,9 @@ async function resolveColdIdentity( } let identity: SubagentIdentityProjection | null | undefined try { - identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent + identity = projections.restore({}, inspected.events, 0, { + seedLength: inspected.meta.seedLength ?? 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 diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index e9a5633189..2f86c0a36a 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -459,11 +459,13 @@ describe('SubagentRuntime.listChildren', () => { values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } }, }) const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect') + const restore = vi.spyOn(ctx.sessionProjections, 'restore') await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{ kind: 'child', id: forkChild, label: 'own label', mode: 'continuable', activity: 'inactive', hasChildren: false, }]) expect(inspect).toHaveBeenCalledTimes(1) + expect(restore).toHaveBeenCalledWith({}, expect.any(Array), 0, { seedLength: seed.length }) }) it.each([ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 823b10216f..ed9d80a83d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1415,6 +1415,9 @@ importers: '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-schedule': + specifier: workspace:^ + version: link:../../client/ui-schedule '@deepseek-ai/dsh-client-ui-session': specifier: workspace:^ version: link:../../client/ui-session @@ -2809,6 +2812,57 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-schedule: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-schedule': + specifier: workspace:^ + version: link:../../schedule/schedule + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-session: devDependencies: '@deepseek-ai/cordis': @@ -6602,6 +6656,10 @@ importers: version: link:../sandbox-local packages/schedule/schedule: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -6636,6 +6694,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-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4acfd5b694..1b447e58fd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -579,6 +579,7 @@ export const LINK_MAP: Readonly> = { WorkflowRunInfo: 'workflow.md', WorkflowStartRequest: 'workflow.md', ProjectionDefinition: 'session-projection.md', + ProjectionInitialization: 'session-projection.md', SessionProjectionMap: 'session-projection.md', SessionProjectionStateMap: 'session-projection.md', ProjectionChangeListener: 'session-projection.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 81a3e124a1..f8f4269867 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1871,6 +1871,11 @@ "symbol": "PresetSpec", "source": "packages/interaction/permission-presets/src/index.ts" }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "ProjectionInitialization", + "source": "packages/session/session-projection/src/index.ts" + }, { "doc": "docs/subsystems/session-projection.md", "symbol": "ProjectionDefinition", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 086db6eb60..f8a76ffdfd 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -83,6 +83,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over the message-feedback sidecar; ratings and notes never enter the Session log, model context, or telemetry.' }, 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side read-only projection of ctx.jobs records; dsh-tool-jobs owns the model-facing behavior.' }, + 'packages/client/ui-schedule': { kind: 'none', reason: 'Browser-side read-only projection of active Schedule records; dsh-schedule owns the model-facing tools and delivery.' }, 'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' }, 'packages/client/ui-input-trigger': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-reference': { kind: 'indirect', reason: 'Browser-side reference selection delegates file guidance and session snapshot preparation to Host-owned providers.' }, diff --git a/snapshots/web/schedule-catalog/catalog.expected.md b/snapshots/web/schedule-catalog/catalog.expected.md new file mode 100644 index 0000000000..36c8268784 --- /dev/null +++ b/snapshots/web/schedule-catalog/catalog.expected.md @@ -0,0 +1,4 @@ +- list "Active reminders": + - listitem: Overdue Review overdue deployment Once Aug 25, 2099, 7:59 PM 1 minute overdue + - listitem: Scheduled Join release review with the release owners, verify the rollout checklist, capture each unresolved dependency, confirm the customer-facing message, compare the staged configuration with the approved release notes, inspect the deployment dashboard for every region, confirm that database migrations completed without warnings, review the rollback steps with the incident commander, verify that support has the final customer timeline, record every owner and deadline, check the public status wording against the internal decision, review the accessibility and localization sign-offs, confirm the monitoring thresholds and alert routes, read back the final launch sequence, document every unresolved question in plain language, keep all technical qualifiers and exception cases visible, include the exact handoff conditions for each downstream team, retain the complete audit context for the final decision, and preserve every final word without truncation. Once Aug 25, 2099, 8:05 PM in 6 minutes + - listitem: Scheduled Check exact cadence Every 301 seconds Aug 25, 2099, 8:05 PM in 6 minutes diff --git a/snapshots/web/schedule-catalog/session.jsonl b/snapshots/web/schedule-catalog/session.jsonl new file mode 100644 index 0000000000..540fddeb49 --- /dev/null +++ b/snapshots/web/schedule-catalog/session.jsonl @@ -0,0 +1,11 @@ +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787644800000,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"turn/start","data":{"turn":1}} +{"type":"user/message","data":{"role":"user","content":[{"type":"text","text":"Show the active reminders."}],"source":{"kind":"user"},"id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Active schedule catalog","messageSeqs":[1],"source":{"kind":"user"}}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"The active reminders are available in the session header."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"{{message:2}}"},"usage":{"inputTokens":4,"outputTokens":10}},"surfaceOp":"append"} +{"type":"schedule/change","data":{"version":1,"operation":"create","schedule":{"id":"catalog-after","kind":"after","prompt":"Review overdue deployment","afterSeconds":60,"scheduledAt":"2099-08-25T11:59:00.000Z"}}} +{"type":"schedule/change","data":{"version":1,"operation":"create","schedule":{"id":"catalog-at","kind":"at","prompt":"Join release review with the release owners, verify the rollout checklist, capture each unresolved dependency, confirm the customer-facing message, compare the staged configuration with the approved release notes, inspect the deployment dashboard for every region, confirm that database migrations completed without warnings, review the rollback steps with the incident commander, verify that support has the final customer timeline, record every owner and deadline, check the public status wording against the internal decision, review the accessibility and localization sign-offs, confirm the monitoring thresholds and alert routes, read back the final launch sequence, document every unresolved question in plain language, keep all technical qualifiers and exception cases visible, include the exact handoff conditions for each downstream team, retain the complete audit context for the final decision, and preserve every final word without truncation.","scheduledAt":"2099-08-25T12:05:01.000Z"}}} +{"type":"schedule/change","data":{"version":1,"operation":"create","schedule":{"id":"catalog-every","kind":"every","prompt":"Check exact cadence","everySeconds":301,"scheduledAt":"2099-08-25T12:05:01.000Z"}}} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/schedule-catalog/snapshot.yml b/snapshots/web/schedule-catalog/snapshot.yml new file mode 100644 index 0000000000..7c8d2b6e17 --- /dev/null +++ b/snapshots/web/schedule-catalog/snapshot.yml @@ -0,0 +1,8 @@ +version: 1 +scenario: schedule-catalog +profile: web +composition: web-schedule +recording: authored +header: + class: web-schedule + pin: true diff --git a/snapshots/web/schedule-catalog/system-prompt.expected.md b/snapshots/web/schedule-catalog/system-prompt.expected.md new file mode 100644 index 0000000000..004b2dc501 --- /dev/null +++ b/snapshots/web/schedule-catalog/system-prompt.expected.md @@ -0,0 +1,37 @@ +You are an AI agent powered by DeepSeek Harness. + +The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. + +You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL. + +You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. + +Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session. + +Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head. + +Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + +When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn. diff --git a/snapshots/web/schedule-catalog/tool-schemas.expected.json b/snapshots/web/schedule-catalog/tool-schemas.expected.json new file mode 100644 index 0000000000..cca88853f1 --- /dev/null +++ b/snapshots/web/schedule-catalog/tool-schemas.expected.json @@ -0,0 +1,761 @@ +{ + "initial": [ + { + "name": "ask_user_question", + "description": "Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.", + "parameters": { + "type": "object", + "properties": { + "questions": { + "type": "array", + "description": "Questions to ask the user before continuing.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "description": "Stable id for this question; echoed in the answer." + }, + "question": { + "type": "string", + "description": "The specific question to ask the user." + }, + "header": { + "type": "string", + "description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"." + }, + "options": { + "type": "array", + "description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "label": { + "type": "string", + "description": "Short user-facing option label." + }, + "description": { + "type": "string", + "description": "One sentence explaining the tradeoff or impact." + } + }, + "required": [ + "label" + ] + } + }, + "multi_select": { + "type": "boolean", + "description": "Whether the user may select more than one option. Defaults to false." + } + }, + "required": [ + "id", + "question" + ] + } + } + }, + "required": [ + "questions" + ] + } + }, + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "exit_plan_mode", + "description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.", + "parameters": { + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The complete plan, as markdown, starting with a # heading that names it." + } + }, + "required": [ + "plan" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "glob", + "description": "Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result returns the first 100 paths in modification-time order, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth." + }, + "path": { + "type": "string", + "description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "grep", + "description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression to search for (ripgrep syntax)." + }, + "path": { + "type": "string", + "description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it." + }, + "include": { + "type": "string", + "description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported." + } + }, + "required": [ + "pattern" + ] + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "job_kill", + "description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the job." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "job_list", + "description": "List your background jobs (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "job_output", + "description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "Job id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "job_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "schedule_create", + "description": "Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, or safe-integer every_seconds of at least 300. Fixed-rate reminders stay creation-aligned, skip missed occurrences, and batch one latest occurrence per overdue rule. Delivery is session-local: the reminder runs on time only while this session is live and otherwise becomes overdue until the session is resumed.", + "parameters": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "Reminder content to present when the target becomes due." + }, + "after_seconds": { + "type": "number", + "description": "Positive safe-integer delay in seconds." + }, + "every_seconds": { + "type": "number", + "description": "Fixed-rate safe-integer interval in seconds, at least 300." + }, + "at": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "time": { + "type": "string" + }, + "time_zone": { + "type": "string" + } + }, + "required": [ + "date", + "time", + "time_zone" + ] + } + ], + "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone." + } + }, + "required": [ + "prompt" + ] + } + }, + { + "name": "schedule_delete", + "description": "Delete one active reminder in the current session by the exact id returned by schedule_create or schedule_list. Unknown or already-finished ids return deleted false.", + "parameters": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Exact session-local schedule id." + } + }, + "required": [ + "id" + ] + } + }, + { + "name": "schedule_list", + "description": "List every active reminder in the current session in creation order, including its exact id, UTC target, scheduled or overdue state, and session-local delivery mode.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "web_search", + "description": "Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.", + "parameters": { + "type": "object", + "properties": { + "queries": { + "type": "array", + "description": "Required search queries; accepts 1–4 items and merges their results.", + "items": { + "type": "string" + } + } + }, + "required": [ + "queries" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 8a8bc37baa..d017d8def1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -245,6 +245,9 @@ "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], + "@deepseek-ai/dsh-client-ui-schedule": ["./packages/client/ui-schedule/src"], + "@deepseek-ai/dsh-client-ui-schedule/client": ["./packages/client/ui-schedule/src/client/index.ts"], + "@deepseek-ai/dsh-client-ui-schedule/invariant": ["./packages/client/ui-schedule/src/invariant.ts"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-user-questions": ["./packages/client/ui-user-questions/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], @@ -256,6 +259,7 @@ "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": ["./packages/client/ui-settings-plugin-inventory/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], + "@deepseek-ai/dsh-schedule/client": ["./packages/schedule/schedule/src/client.ts"], // sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes, // so the generic wildcard cannot map these three package names. "@deepseek-ai/dsh-sdk-client": ["./packages/sdk/client/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 291b813300..6b3b88e30a 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -77,6 +77,7 @@ { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-jobs" }, + { "path": "./packages/client/ui-schedule" }, { "path": "./packages/client/ui-directory-picker-browse" }, { "path": "./packages/client/ui-directory-picker-native" }, { "path": "./packages/client/ui-goal" }, From 5fe7dc333f03c2f7c73dd0303a0edb3160a52c7e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 25 Aug 2026 22:17:55 +0800 Subject: [PATCH 19/72] fix(session): reconcile cached projection hints --- ...nd-projection-owned-client-state.i18n.yaml | 4 +- ...tions-and-projection-owned-client-state.md | 4 +- ...ns-and-projection-owned-client-state.zh.md | 4 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 11 +-- docs/subsystems/session-projection.zh.md | 11 +-- .../src/client/sessions/manager.ts | 14 ++- .../src/client/sessions/projection-store.ts | 70 +++++++++----- .../src/client/sessions/session.ts | 9 +- .../tests/manager.client.spec.ts | 13 ++- .../tests/projection-store.client.spec.ts | 75 ++++++++++++--- .../src/client/ScheduleCatalogAction.tsx | 95 ++++++++++--------- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 2 +- .../session-projection-cache/README.zh.md | 2 +- .../session-projection-cache/src/index.ts | 24 ++--- 20 files changed, 216 insertions(+), 140 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 6660f864d6..0b38f31d9f 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.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-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: e47f2fc75ecbca51d01af077f6c6ab98f4e275f9 -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 527a4eb6b6765cba95d6067f2be60bff8f31a559 +2026-08-25-session-observations-and-projection-owned-client-state.md: 0fa69dde28eadc860d426ea511f4aaf1356c7afa +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: ab601d7e6839eba6370564a25f92aa5cef99ca40 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index e47f2fc75e..0fa69dde28 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -108,11 +108,11 @@ These distinctions prevent one overloaded `undefined` from representing cache mi | Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent | | Projection frame | One whole key | Event sequence carried by the frame | Not applicable | -The Client stores one row per key with its sequence number. A newer hint, baseline, or frame replaces a row; an equal or older input is ignored. Reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence. +The Client stores one row per key with its provenance and sequence number. A list hint fills or advances only a tentative row. A complete opening baseline replaces or clears tentative rows even when a cache hint claims a higher sequence, while preserving an authoritative frame newer than the opening cut. Frames use higher-sequence-wins and promote an equal-sequence hint to authoritative state. A replacement control baseline first discards rows beyond its durable cut, then installs its complete values. The list view reads the same per-Session store as the opened Session. Hints can populate title, preset, and other list presentation before follow completes; the opening baseline then converges that state without creating a second summary-only authority. -The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames under one higher-sequence-wins rule. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer row. +The per-Session Client projection store never folds Session events; it only reconciles finished hints, complete baselines, and whole-value frames under those source-aware rules. Data that is not derived from one Session remains outside projections. `llm.models` owns the Host-generation model catalog, and `agentPreset.list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index 527a4eb6b6..ab601d7e68 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -108,11 +108,11 @@ Projection 的三种交付状态含义不同: | Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 | | Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 | -Client 为每个 key 保存带 sequence number 的一行。更新的 hint、baseline 或 frame 会替换 row;相同或更旧的输入被忽略。因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。 +Client 为每个 key 保存带来源与 sequence number 的一行。List hint 只能填充或推进暂定 row。完整 opening baseline 即使面对声称更高 sequence 的 cache hint,也会替换或清除暂定 row,同时保留晚于 opening cut 的权威 frame。Frame 继续使用 higher-sequence-wins,并会把相同 sequence 的 hint 提升为权威状态。Replacement control baseline 会先丢弃超出其 durable cut 的 row,再安装完整值。 List view 与已打开 Session 读取同一个 per-Session store。Hints 可以在 follow 完成前填充 title、preset 和其他 list presentation;opening baseline 随后收敛这份状态,而不会建立第二套 summary-only authority。 -每个 Session 的 Client projection store 按一条 higher-sequence-wins 规则接收 list hints、follow baseline 和后续 whole-value frame。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 row。 +每个 Session 的 Client projection store 从不折叠 Session event;它只按上述来源感知规则协调成品 hint、完整 baseline 与 whole-value frame。 不由单个 Session 派生的数据不进入 projection。`llm.models` 拥有当前 Host generation 的 model catalog,`agentPreset.list` 拥有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8613048b0b..0b17881cd7 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: 8543498400d5f8f2e0230db5898296f9942c5640 -config-catalog.zh.md: a162650a53a7e42f61a695120673d804b3737f68 +config-catalog.md: 9ac708ebf60e12c9c061a0d809ca5ac7eebfd8a4 +config-catalog.zh.md: f32e55abc884ee8b76ea4fdf47bd7601649d9af5 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8543498400..9ac708ebf6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1839,7 +1839,7 @@ export interface Config { } ``` -Source: [`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts) +Source: [`packages/session/session-projection-cache/src/index.ts:47`](../packages/session/session-projection-cache/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a162650a53..f32e55abc8 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1841,7 +1841,7 @@ export interface Config { } ``` -来源:[`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts) +来源:[`packages/session/session-projection-cache/src/index.ts:47`](../packages/session/session-projection-cache/src/index.ts) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 361004f650..367a2ce8cd 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: 2e64dca6b22b39771f6ab5ccc594e894a9b3785f -session-projection.zh.md: 356668e24e12e95b2b8abb92c4878a15f9ecec07 +session-projection.md: 289e69e4f03020beed835ff6c7012a4b6934dfe3 +session-projection.zh.md: 14b0acd8a7ee3ef33e85ec122fb29625ba352399 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 2e64dca6b2..289e69e4f0 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -118,12 +118,11 @@ The persisted projection cache service. Opens the `session_projcache` domain at ```ts cordis-catalog /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), each cut carried with its watermark - * so a client value store can seed under its higher-seq-wins rule — as - * stale as the last durable checkpoint but never wrong, and never from an - * unrelated log (the caller's header is the identity witness). Fresher - * paths (the history tail baseline, {@link coldSnapshot}) supersede these - * values whenever a session is actually opened. + * rows (version-matching keys only), each cut carried with its watermark so + * a client value store can prewarm tentative rows. The caller's header keeps + * unrelated lifecycles out, but a row may lag the log or overreach a + * crash-repaired truncation; the exact history or {@link coldSnapshot} + * baseline replaces or clears hints whenever a session is opened. * @param meta - the listed session's header (identity witness; no log read). * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 356668e24e..14b0acd8a7 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -118,12 +118,11 @@ The persisted projection cache service. Opens the `session_projcache` domain at ```ts cordis-catalog /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), each cut carried with its watermark - * so a client value store can seed under its higher-seq-wins rule — as - * stale as the last durable checkpoint but never wrong, and never from an - * unrelated log (the caller's header is the identity witness). Fresher - * paths (the history tail baseline, {@link coldSnapshot}) supersede these - * values whenever a session is actually opened. + * rows (version-matching keys only), each cut carried with its watermark so + * a client value store can prewarm tentative rows. The caller's header keeps + * unrelated lifecycles out, but a row may lag the log or overreach a + * crash-repaired truncation; the exact history or {@link coldSnapshot} + * baseline replaces or clears hints whenever a session is opened. * @param meta - the listed session's header (identity witness; no log read). * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 1a76102eaa..7bb1df6c80 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -491,18 +491,16 @@ export class SessionManager { session.handleBlank(s.blank) session.handleRunning(s.running) } - // Seed each row's projection baseline into the per-session value - // store (cold titles surface without opening the session). Per-key - // apply, not seed(): the list block is a partial baseline — the - // cold cache serves only version-matching keys — so an absent key - // must not clear; higher-seq-wins still keeps a stale list block - // from overwriting a newer push frame or tail baseline. + // Prewarm each row's projection hints (cold titles surface without + // opening the session). The list block is partial, so an absent key + // must not clear; hints never replace an authoritative frame or + // successful opening baseline, even if the cache claims a higher cut. for (const s of result.value.items) { const block = s.projections if (block === undefined) continue const store = this.projectionStore(s.sessionId) const values = block.values as Record - for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq) + for (const key of Object.keys(values)) store.prewarm(key, values[key], block.asOfSeq) } } else { this.listState = 'error' @@ -725,7 +723,7 @@ export class SessionManager { if (projections !== undefined) { const store = this.projectionStore(summary.sessionId) for (const [key, value] of Object.entries(projections.values)) { - store.apply(key, value, projections.asOfSeq) + store.prewarm(key, value, projections.asOfSeq) } } if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) { diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts index fd0ad1b5d2..e48f24679b 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -2,11 +2,13 @@ * Generic per-session projection value store (push model; see the * session-projection subsystem page, docs/subsystems/session-projection.md): * the host is the only computation site; the client holds finished - * whole values per key — `key → { value, seq }` — seeded by a follow opening - * baseline and updated by Session Controller `projection` frames, - * under the single rule **higher seq wins**. No client-side domain folding - * exists: a domain ships projection support with zero client code. Per-key - * bare observable faces feed `useProjection` (ui-renderer binds them). + * whole values per key — `key → { value, seq, provenance }`. Session-list and + * session-added blocks are tentative prewarm hints; a successful follow + * opening installs the complete authoritative baseline, and Session Controller + * `projection` frames advance authoritative rows by sequence. No client-side + * domain folding exists: a domain ships projection support with zero client + * code. Per-key bare observable faces feed `useProjection` (ui-renderer binds + * them). */ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -51,10 +53,11 @@ export interface ProjectionsBaseline { values: Readonly> } -/** One key's row: the latest finished value and the seq it is consistent with. */ +/** One key's row: the latest finished value, its cut, and its trust level. */ interface Row { value: unknown seq: number + provenance: 'prewarm' | 'authoritative' } /** Per-key notification channel: the bare face plus its batching notifier. */ @@ -64,14 +67,15 @@ interface Channel { } /** - * One session's projection values. Framework semantics, uniform across every - * key: a baseline seeds rows at its cut, a push frame updates one row, and in - * both paths a lower-or-equal seq loses — a replayed frame cannot regress a - * value, a stale baseline cannot overwrite a newer frame. A key the store has - * never seen reads `undefined` (capability absent). Faces are identity-stable - * per key (create-on-demand, cached) so the React side binds each exactly - * once; the store-level channel (`subscribeAny`) serves coarse consumers (the - * manager's list projection reads the `title` key). + * One session's projection values. A list hint can fill or advance only a + * tentative row. A complete baseline replaces or clears every tentative row, + * regardless of its claimed sequence, while preserving authoritative frames + * newer than the baseline cut. Frames use higher-sequence-wins after promoting + * an equal-sequence hint to authoritative state. A key the store has never seen + * reads `undefined` (capability absent). Faces are identity-stable per key + * (create-on-demand, cached) so the React side binds each exactly once; the + * store-level channel (`subscribeAny`) serves coarse consumers (the manager's + * list projection reads the `title` key). */ export class ProjectionValueStore { private readonly rows = new Map() @@ -124,6 +128,22 @@ export class ProjectionValueStore { return this.anyNotifier.subscribe(listener) } + /** + * Prewarm one tentative value from a partial Session list or session-added + * block. Hints compete only with other hints; once an authoritative value is + * known, no later list refresh may replace it. + * @param key - projection key. + * @param value - whole cached value. + * @param seq - the cache row's claimed watermark. + */ + prewarm(key: string, value: unknown, seq: number): void { + const row = this.rows.get(key) + if (row?.provenance === 'authoritative') return + if (row !== undefined && seq <= row.seq) return + this.rows.set(key, { value, seq, provenance: 'prewarm' }) + this.changed(key) + } + /** * Apply one finished value from the Session control stream. * @param key - projection key. @@ -132,27 +152,31 @@ export class ProjectionValueStore { */ apply(key: string, value: unknown, seq: number): void { const row = this.rows.get(key) - if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop - this.rows.set(key, { value, seq }) + if (row !== undefined && (seq < row.seq || (seq === row.seq && row.provenance === 'authoritative'))) return + this.rows.set(key, { value, seq, provenance: 'authoritative' }) this.changed(key) } /** - * Seed from a history tail page's projections block: every carried key - * lands under the same seq rule as frames; a key the block omits is - * capability-absent as of the cut — its row clears unless a newer frame - * already superseded the cut (a stale baseline can neither overwrite nor - * clear newer values). + * Seed from a complete history or control projections block. The baseline + * replaces every tentative hint, including one whose cache watermark is + * higher, and clears omitted hints. Only an authoritative frame newer than + * the cut survives. * @param baseline - the response's projections block. */ seed(baseline: ProjectionsBaseline): void { // Erased walk: the framework crosses the open key space; per-key typing // is re-established at the consumer (useProjection's map lookup). const values = baseline.values as Record - for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq) + for (const key of Object.keys(values)) { + const row = this.rows.get(key) + if (row?.provenance === 'authoritative' && row.seq > baseline.asOfSeq) continue + this.rows.set(key, { value: values[key], seq: baseline.asOfSeq, provenance: 'authoritative' }) + this.changed(key) + } for (const [key, row] of this.rows) { if (Object.hasOwn(values, key)) continue - if (row.seq > baseline.asOfSeq) continue + if (row.provenance === 'authoritative' && row.seq > baseline.asOfSeq) continue this.rows.delete(key) this.changed(key) } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 182d8514d9..a98c25ea02 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -107,9 +107,10 @@ export class Session implements SessionFace { /** * Per-session projection value store (push model; see the session-projection * subsystem page, docs/subsystems/session-projection.md): finished whole - * values computed on the Host, seeded by the tail page's - * projections block and updated by Session Controller control frames under the - * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)` + * values computed on the Host. Partial list blocks prewarm tentative rows; + * the tail page installs the complete authoritative baseline, and Session + * Controller frames advance authoritative rows by sequence. Keys are read + * via `projections.faceOf(key)` * (the useProjection resolution face); the conversation snapshot never * carries projection values, and no client-side domain folding exists. * Manager-owned when constructed through SessionManager (frames route and @@ -574,7 +575,7 @@ export class Session implements SessionFace { } } - /** Replace the complete contiguous window and apply page-owned projection metadata. */ + /** Replace the complete contiguous window and install its authoritative projection baseline. */ private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { this.baseSeq = entries[0]?.event.seq ?? 0 this.hasMore = hasMore diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index d271845e78..73abe33b9f 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -151,25 +151,30 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) - it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { + it('prewarms cold titles from list and session-added hints without replacing authoritative values', async () => { const api = new FakeApiClient() const manager = new SessionManager(api, fakeRemote(api)) - // A push frame landed before the list (S2's title is newer than the block's cut). + // A push frame landed before the list. Even a later cache watermark stays + // tentative and cannot replace this authoritative value. manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9, }) api.onList = () => Promise.resolve(ok({ items: [ { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } }, - { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } }, + { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 12, values: { title: 'List stale' } } }, ] as never[], })) await manager.refreshList() const items = manager.getListSnapshot().items // Cold row: title surfaces straight from the list block — no open, no history. expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached') - // The stale list block (seq 5) cannot overwrite the newer push frame (seq 9). expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed') + manager.handleSessionAdded({ + ...summary(S2, { updatedAt: 300 }), + projections: { asOfSeq: 15, values: { title: 'Added stale' } }, + }) + expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)?.title).toBe('Pushed') }) it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index d72be91338..c0231c6786 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -1,18 +1,17 @@ /** * Projection value store (push model; session-projection subsystem page: - * docs/subsystems/session-projection.md): the single - * higher-seq-wins rule on both paths (a stale baseline cannot overwrite a - * newer push frame; a replayed frame cannot regress), capability absence as - * undefined, generation truncation, and the Session/manager wiring (tail-page - * seeding, control-stream projection routing pre- and post-instantiation, the - * list rows' title projection). + * docs/subsystems/session-projection.md): tentative list prewarm versus + * authoritative baselines and frames, capability absence as undefined, + * generation truncation, and the Session/manager wiring (tail-page seeding, + * control-stream projection routing pre- and post-instantiation, the list + * rows' title projection). */ import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' import { entries, plainTurn } from './event-script.client.ts' // Test-domain keys merged into the projection map (the Service Definition package's @@ -42,18 +41,31 @@ describe('Session projection value semantics', () => { expect(store.get('test/marks')).toEqual({ marks: ['a', 'b'] }) }) - it('a stale baseline can neither overwrite nor clear a newer frame; a fresh one reseeds and clears', () => { + it('prewarms only tentative rows and promotes an equal-seq authoritative frame', () => { const store = new ProjectionValueStore() - store.apply('test/marks', { marks: ['frame-20'] }, 20) - // Stale cut: carried key loses to the newer frame; omitted key survives. + store.prewarm('test/marks', { marks: ['hint-5'] }, 5) + store.prewarm('test/marks', { marks: ['stale-hint'] }, 3) + expect(store.get('test/marks')).toEqual({ marks: ['hint-5'] }) + store.prewarm('test/marks', { marks: ['hint-9'] }, 9) + store.apply('test/marks', { marks: ['frame-9'] }, 9) + store.prewarm('test/marks', { marks: ['later-hint'] }, 20) + expect(store.get('test/marks')).toEqual({ marks: ['frame-9'] }) + }) + + it('a complete baseline replaces hints but preserves newer authoritative frames', () => { + const store = new ProjectionValueStore() + store.prewarm('test/marks', { marks: ['hint-20'] }, 20) + store.prewarm('hint-only', 'stale', 20) + store.apply('frame-only', 'frame-20', 20) store.seed({ asOfSeq: 10, values: { 'test/marks': { marks: ['baseline-10'] } } }) + expect(store.get('test/marks')).toEqual({ marks: ['baseline-10'] }) + expect(store.get('hint-only')).toBeUndefined() + expect(store.get('frame-only')).toBe('frame-20') + store.apply('test/marks', { marks: ['frame-20'] }, 20) + store.seed({ asOfSeq: 15, values: { 'test/marks': { marks: ['baseline-15'] } } }) expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] }) - store.seed({ asOfSeq: 15, values: {} }) - expect(store.get('test/marks')).toEqual({ marks: ['frame-20'] }) - // Fresh cut: carried key reseeds… store.seed({ asOfSeq: 30, values: { 'test/marks': { marks: ['baseline-30'] } } }) expect(store.get('test/marks')).toEqual({ marks: ['baseline-30'] }) - // …and an omitting fresh cut clears (capability absent as of the cut). store.seed({ asOfSeq: 40, values: {} }) expect(store.get('test/marks')).toBeUndefined() }) @@ -104,7 +116,7 @@ describe('Session tail-page seeding', () => { it('retains a prewarmed projection when opening the Session fails', async () => { const api = new FakeApiClient() const projections = new ProjectionValueStore() - projections.apply('test/marks', { marks: ['cached'] }, 5) + projections.prewarm('test/marks', { marks: ['cached'] }, 5) const session = new Session(SID, api, fakeRemote(api), { projections }) api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', @@ -129,6 +141,39 @@ describe('Session tail-page seeding', () => { expect(session.projections.get('test/marks')).toEqual({ marks: ['from-baseline'] }) }) + it('replaces a higher-seq prewarm hint after a successful opening', async () => { + const api = new FakeApiClient() + const projections = new ProjectionValueStore() + projections.prewarm('test/marks', { marks: ['stale-list'] }, 9) + const session = new Session(SID, api, fakeRemote(api), { projections }) + api.onHistory = () => Promise.resolve(ok({ + records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, + projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['authoritative'] } } }, + } as never)) + + await session.open() + + expect(session.getSnapshot().openState).toBe('open') + expect(session.projections.get('test/marks')).toEqual({ marks: ['authoritative'] }) + }) + + it('preserves an authoritative frame that lands while opening waits for its older baseline', async () => { + const api = new FakeApiClient() + const history = deferred>>() + api.onHistory = () => history.promise + const session = new Session(SID, api, fakeRemote(api)) + + const opening = session.open() + session.projections.apply('test/marks', { marks: ['live-3'] }, 3) + history.resolve(ok({ + records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, + projections: { asOfSeq: 2, values: { 'test/marks': { marks: ['baseline-2'] } } }, + } as never)) + await opening + + expect(session.projections.get('test/marks')).toEqual({ marks: ['live-3'] }) + }) + it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => { const api = new FakeApiClient() const session = new Session(SID, api, fakeRemote(api)) diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx index 8d1e7a4675..4ef243b99e 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx @@ -138,54 +138,59 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule setOpen(false) triggerRef.current?.focus() } + const toggleCatalog = (): void => { + setNow(Date.now()) + setOpen(current => !current) + } + const trigger = ( + + ) + const catalog = open + ? ( +
    + {rows.map((record) => { + const overdue = Date.parse(record.scheduledAt) <= now + return ( +
  • + + + {record.prompt} + + {formatScheduleFrequency(record, t)} + + {formatScheduleLocalTime(record.scheduledAt)} + + + {formatScheduleRelative(record.scheduledAt, now, t)} + + +
  • + ) + })} +
+ ) + : null return (
- - {open - ? ( -
    - {rows.map((record) => { - const overdue = Date.parse(record.scheduledAt) <= now - return ( -
  • - - - {record.prompt} - - {formatScheduleFrequency(record, t)} - - {formatScheduleLocalTime(record.scheduledAt)} - - - {formatScheduleRelative(record.scheduledAt, now, t)} - - -
  • - ) - })} -
- ) - : null} + {trigger} + {catalog}
) } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0d615e8c25..37d2c3468d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1400,7 +1400,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'cachedSnapshot( meta: SessionHeader, keys?: readonly Extract[], ): ProjectionSnapshot | undefined', - description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can seed under its higher-seq-wins rule — as stale as the last durable checkpoint but never wrong, and never from an unrelated log (the caller\'s header is the identity witness). Fresher paths (the history tail baseline, coldSnapshot) supersede these values whenever a session is actually opened.', + description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can prewarm tentative rows. The caller\'s header keeps unrelated lifecycles out, but a row may lag the log or overreach a crash-repaired truncation; the exact history or coldSnapshot baseline replaces or clears hints whenever a session is opened.', parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'keys', description: 'optional projection keys required by the caller\'s audience.' }], returns: 'the cut (`asOfSeq` = lowest served-row watermark), or `undefined` when no usable row exists for this lifecycle.', }, diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index 609f146f24..ce6d1644e2 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: 33908578a5127f2b6bb78ed7467833aaaa2cf085 -README.zh.md: 0ca410f91562360d85faadf4cf64cb61ac467482 +README.md: bddff27bf89c31049c72ed2e8027f452cd6fbae2 +README.zh.md: 3695fff30cb3c7741f448f0bb46660fd6a73a331 diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 33908578a5..bddff27bf8 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -28,7 +28,7 @@ Both `Config` fields are required (no defaults): flush cadence is a deployment c ## Listing read (`cachedSnapshot(meta)`) -The zero-I/O rung: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column. +The zero-I/O rung: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut. `asOfSeq` is the lowest served-row watermark, and the list carrier uses the block only to prewarm tentative rows. Newer hints may replace older hints, but no hint replaces an authoritative opening baseline or control frame; a successful exact opening replaces or clears hints regardless of their claimed sequence. The record may lag the log or overreach a crash-repaired truncation, which the exact cold/open path validates and refolds. Host-only rows are never returned. `undefined` means no usable client row exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column. ## Cold read (`coldSnapshot(id, signal?)`) diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index 0ca410f915..3695fff30c 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -28,7 +28,7 @@ ## 列表读(`cachedSnapshot(meta)`) -零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。 +零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回。`asOfSeq` 取所服务行的最低水位,列表载体只用该块预热暂定 row。较新的 hint 可以替换较旧的 hint,但任何 hint 都不能替换权威 opening baseline 或 control frame;成功的精确打开会忽略 hint 声称的 sequence,直接替换或清除它。存储记录可能落后于日志,也可能越过崩溃修复后的截断点;精确 cold/open 路径会校验并重新折叠。host-only 行永不返回。`undefined` 表示无可用客户端行(未知 id、无关生命周期或无可用行);api-proxy 列表载体将其转为列缺席。 ## 冷读(`coldSnapshot(id, signal?)`) diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index f1f50bbba0..7bbcd20cf4 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -3,10 +3,11 @@ * 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`). The cache is a fold - * shortcut, never an authority: a row is possibly stale (its `seq` - * says how stale) but never wrong, so every write path is fail-soft (a lost - * write costs a longer tail replay on the next cold read) and a - * `ver` mismatch discards the row instead of migrating it. Design + * shortcut, never an authority: an identity-matching row may lag the log or + * overreach a crash-repaired truncation, so exact reads validate and refold it. + * Every write path is fail-soft (a lost write costs a longer tail replay on + * the next cold read), and a `ver` mismatch discards the row instead of + * migrating it. Design * authority: the session-projection RFC * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). * @module @deepseek-ai/dsh-session-projection-cache @@ -110,12 +111,11 @@ export class SessionProjectionCache extends Service { /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), each cut carried with its watermark - * so a client value store can seed under its higher-seq-wins rule — as - * stale as the last durable checkpoint but never wrong, and never from an - * unrelated log (the caller's header is the identity witness). Fresher - * paths (the history tail baseline, {@link coldSnapshot}) supersede these - * values whenever a session is actually opened. + * rows (version-matching keys only), each cut carried with its watermark so + * a client value store can prewarm tentative rows. The caller's header keeps + * unrelated lifecycles out, but a row may lag the log or overreach a + * crash-repaired truncation; the exact history or {@link coldSnapshot} + * baseline replaces or clears hints whenever a session is opened. * @param meta - the listed session's header (identity witness; no log read). * @param keys - optional projection keys required by the caller's audience. * @returns the cut (`asOfSeq` = lowest served-row watermark), or @@ -131,8 +131,8 @@ export class SessionProjectionCache extends Service { const servedKeys = Object.keys(values) if (servedKeys.length === 0) return undefined // The block carries ONE cut: the lowest served watermark is the seq every - // value is at least current as of (under-claiming is safe under - // higher-seq-wins; over-claiming would let a stale value outrank pushes). + // value is at least current as of. Under-claiming is safe; over-claiming + // would misorder this hint against other tentative observations. const asOfSeq = Math.min(...servedKeys.map(key => (record.rows[key] as { seq: number }).seq)) return { asOfSeq, values } } From cdba045dfcb146bb6c8f1dbed1113d550451cbba Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 25 Aug 2026 23:18:46 +0800 Subject: [PATCH 20/72] fix(session): trust authoritative projection frames --- ...s-and-projection-owned-client-state.i18n.yaml | 4 ++-- ...rvations-and-projection-owned-client-state.md | 4 ++-- ...tions-and-projection-owned-client-state.zh.md | 4 ++-- docs/subsystems/session-projection.i18n.yaml | 4 ++-- docs/subsystems/session-projection.md | 9 +++++---- docs/subsystems/session-projection.zh.md | 9 +++++---- .../src/client/sessions/projection-store.ts | 14 +++++++------- .../tests/projection-store.client.spec.ts | 6 ++++-- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../session-projection-cache/README.i18n.yaml | 4 ++-- .../session/session-projection-cache/README.md | 6 +++--- .../session-projection-cache/README.zh.md | 6 +++--- .../session/session-projection-cache/src/spec.ts | 16 ++++++++++------ packages/session/session-projection/src/index.ts | 9 +++++---- 14 files changed, 53 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 0b38f31d9f..53fdc566f2 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.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-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: 0fa69dde28eadc860d426ea511f4aaf1356c7afa -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: ab601d7e6839eba6370564a25f92aa5cef99ca40 +2026-08-25-session-observations-and-projection-owned-client-state.md: f1bb2d42d7f5d8e00c1a13a2d5297f7342bdd4c3 +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 15e55f17b7fa0e38a3c298e89beabf2605b60ddc diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index 0fa69dde28..f1bb2d42d7 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -94,7 +94,7 @@ A Client-visible fact belongs to `SessionProjectionMap` when its value is determ The three projection delivery states have different meanings: -- A Session-list hint is optional, partial, and possibly stale. A missing key means unknown, so a list consumer must not invent an empty value or deployment default. +- A Session-list hint is optional, partial, and unvalidated against the current log extent. It may be stale or claim a cut removed by crash repair. A missing key means unknown, so a list consumer must not invent an empty value or deployment default. - A follow opening baseline is the complete set of client-visible projection capabilities registered at its cursor. A missing key there means the capability is absent for that Host composition. - An explicit `null` is a domain-computed no-value result. It is distinct from a missing list hint and survives JSON transport. @@ -108,7 +108,7 @@ These distinctions prevent one overloaded `undefined` from representing cache mi | Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent | | Projection frame | One whole key | Event sequence carried by the frame | Not applicable | -The Client stores one row per key with its provenance and sequence number. A list hint fills or advances only a tentative row. A complete opening baseline replaces or clears tentative rows even when a cache hint claims a higher sequence, while preserving an authoritative frame newer than the opening cut. Frames use higher-sequence-wins and promote an equal-sequence hint to authoritative state. A replacement control baseline first discards rows beyond its durable cut, then installs its complete values. +The Client stores one row per key with its provenance and sequence number. A list hint fills or advances only a tentative row. The first authoritative frame replaces a tentative hint regardless of its claimed sequence; later authoritative frames use higher-sequence-wins. A complete opening baseline replaces or clears tentative rows while preserving an authoritative frame newer than the opening cut. A replacement control baseline first discards rows beyond its durable cut, then installs its complete values. The list view reads the same per-Session store as the opened Session. Hints can populate title, preset, and other list presentation before follow completes; the opening baseline then converges that state without creating a second summary-only authority. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index ab601d7e68..15e55f17b7 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -94,7 +94,7 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view` Projection 的三种交付状态含义不同: -- Session-list hint 是可选、部分且可能陈旧的数据。key 缺失表示未知,因此 list 消费方不得自行补成空值或部署默认值。 +- Session-list hint 是可选、部分且未经当前日志范围校验的数据。它可能陈旧,也可能声称一个已被崩溃修复移除的 cut。key 缺失表示未知,因此 list 消费方不得自行补成空值或部署默认值。 - Follow opening baseline 是其 cursor 上所有已注册 Client 可见 projection capability 的完整集合。此处缺少 key 表示当前 Host composition 不具备该 capability。 - 显式 `null` 是领域计算出的无值结果。它不同于 list hint 缺失,并且能够完整通过 JSON transport。 @@ -108,7 +108,7 @@ Projection 的三种交付状态含义不同: | Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 | | Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 | -Client 为每个 key 保存带来源与 sequence number 的一行。List hint 只能填充或推进暂定 row。完整 opening baseline 即使面对声称更高 sequence 的 cache hint,也会替换或清除暂定 row,同时保留晚于 opening cut 的权威 frame。Frame 继续使用 higher-sequence-wins,并会把相同 sequence 的 hint 提升为权威状态。Replacement control baseline 会先丢弃超出其 durable cut 的 row,再安装完整值。 +Client 为每个 key 保存带来源与 sequence number 的一行。List hint 只能填充或推进暂定 row。首个权威 frame 无论暂定 hint 声称的 sequence 多高都会替换它;后续权威 frame 之间才使用 higher-sequence-wins。完整 opening baseline 会替换或清除暂定 row,同时保留晚于 opening cut 的权威 frame。Replacement control baseline 会先丢弃超出其 durable cut 的 row,再安装完整值。 List view 与已打开 Session 读取同一个 per-Session store。Hints 可以在 follow 完成前填充 title、preset 和其他 list presentation;opening baseline 随后收敛这份状态,而不会建立第二套 summary-only authority。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 367a2ce8cd..1fd53e148c 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: 289e69e4f03020beed835ff6c7012a4b6934dfe3 -session-projection.zh.md: 14b0acd8a7ee3ef33e85ec122fb29625ba352399 +session-projection.md: f8cf86c17079b4fbfb528fede1815feb0b4f6918 +session-projection.zh.md: 0e61158b0ab37fe8423d2f7c38e9edddbf2cd478 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 289e69e4f0..f8cf86c170 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -272,10 +272,11 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined /** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated - * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key - * absent (a cold or listing consumer treats it as not-yet-available and a - * fuller read path refolds it). The zero-I/O rung of the read ladder — - * values are as stale as their rows, never wrong. + * `view` of the schema-validated stored state; mismatched, malformed, or + * absent rows leave their key absent. The current log extent is unknown, so + * returned values are tentative hints: a row may trail the log or overreach + * a crash-repaired truncation. Exact restore validates the cut before using + * a row as authoritative state. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 14b0acd8a7..0e61158b0a 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -272,10 +272,11 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined /** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated - * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key - * absent (a cold or listing consumer treats it as not-yet-available and a - * fuller read path refolds it). The zero-I/O rung of the read ladder — - * values are as stale as their rows, never wrong. + * `view` of the schema-validated stored state; mismatched, malformed, or + * absent rows leave their key absent. The current log extent is unknown, so + * returned values are tentative hints: a row may trail the log or overreach + * a crash-repaired truncation. Exact restore validates the cut before using + * a row as authoritative state. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts index e48f24679b..52baf936c8 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -70,12 +70,12 @@ interface Channel { * One session's projection values. A list hint can fill or advance only a * tentative row. A complete baseline replaces or clears every tentative row, * regardless of its claimed sequence, while preserving authoritative frames - * newer than the baseline cut. Frames use higher-sequence-wins after promoting - * an equal-sequence hint to authoritative state. A key the store has never seen - * reads `undefined` (capability absent). Faces are identity-stable per key - * (create-on-demand, cached) so the React side binds each exactly once; the - * store-level channel (`subscribeAny`) serves coarse consumers (the manager's - * list projection reads the `title` key). + * newer than the baseline cut. The first authoritative frame replaces any + * tentative hint; later authoritative frames use higher-sequence-wins. A key + * the store has never seen reads `undefined` (capability absent). Faces are + * identity-stable per key (create-on-demand, cached) so the React side binds + * each exactly once; the store-level channel (`subscribeAny`) serves coarse + * consumers (the manager's list projection reads the `title` key). */ export class ProjectionValueStore { private readonly rows = new Map() @@ -152,7 +152,7 @@ export class ProjectionValueStore { */ apply(key: string, value: unknown, seq: number): void { const row = this.rows.get(key) - if (row !== undefined && (seq < row.seq || (seq === row.seq && row.provenance === 'authoritative'))) return + if (row?.provenance === 'authoritative' && seq <= row.seq) return this.rows.set(key, { value, seq, provenance: 'authoritative' }) this.changed(key) } diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index c0231c6786..9cdc26dd3c 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -157,11 +157,13 @@ describe('Session tail-page seeding', () => { expect(session.projections.get('test/marks')).toEqual({ marks: ['authoritative'] }) }) - it('preserves an authoritative frame that lands while opening waits for its older baseline', async () => { + it('preserves an authoritative frame below a higher hint while opening waits for its older baseline', async () => { const api = new FakeApiClient() const history = deferred>>() api.onHistory = () => history.promise - const session = new Session(SID, api, fakeRemote(api)) + const projections = new ProjectionValueStore() + projections.prewarm('test/marks', { marks: ['hint-9'] }, 9) + const session = new Session(SID, api, fakeRemote(api), { projections }) const opening = session.open() session.projections.apply('test/marks', { marks: ['live-3'] }, 3) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 37d2c3468d..e83e6d7553 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1479,7 +1479,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract[], ): Partial', - description: 'View a checkpoint\'s rows without any log read: for every registered client-visible unit whose row\'s `ver` matches, serve the schema-validated `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key absent (a cold or listing consumer treats it as not-yet-available and a fuller read path refolds it). The zero-I/O rung of the read ladder — values are as stale as their rows, never wrong.', + description: 'View a checkpoint\'s rows without any log read: for every registered client-visible unit whose row\'s `ver` matches, serve the schema-validated `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key absent. The current log extent is unknown, so returned values are tentative hints: a row may trail the log or overreach a crash-repaired truncation. Exact restore validates the cut before using a row as authoritative state.', parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'keys', description: 'optional wire keys to view.' }], returns: 'whole values per key with a usable row; empty when none.', }, diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index ce6d1644e2..39abe56528 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: bddff27bf89c31049c72ed2e8027f452cd6fbae2 -README.zh.md: 3695fff30cb3c7741f448f0bb46660fd6a73a331 +README.md: 898af3feb69fcf709cdbb3d089aebabc40c278d4 +README.zh.md: 0663f6b95fa2168a9561cbda24498fefa1dfe55f diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index bddff27bf8..898af3feb6 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -4,14 +4,14 @@ English | [中文](README.zh.md) 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: +A stored row `(key → {ver, seq, val})` is a disposable fold shortcut, never an authority. The zero-I/O listing path can expose it only as a tentative hint: the row may lag the log, or crash repair may truncate the log below its claimed `seq`. An exact cold or opening read validates the current log extent and refolds instead of accepting a row that no longer fits. Consequences the implementation commits to: -- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value. +- **Every background write is fail-soft.** A failed durable write logs a warning and retains the previous row; the next write or exact cold read self-heals. A crash between writes normally costs a longer tail replay, while crash repair can turn the retained row into a tentative overreach until the exact path validates it. - **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. - **A row must pass the live unit's `stateSchema`.** A malformed row is omitted from the zero-I/O view and rejected by restore so the cold-read ladder refolds it from the log. - **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. - **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values. -- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it. +- **The log leads each checkpoint write.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so the cache cannot lead the log when the write commits. Later crash repair may truncate the log below an existing row; exact reads detect that overreach before returning authoritative state. ## Write policy diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index 3695fff30c..0663f6b95f 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -4,14 +4,14 @@ 持久投影缓存(`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.zh.md)(persisted projection cache 一节)。 -一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: +一条存储行 `(key → {ver, seq, val})` 是可丢弃的折叠捷径,绝不是权威。零 I/O 列表路径只能把它公开为暂定 hint:该行可能落后于日志,崩溃修复也可能把日志截断到其声称的 `seq` 之前。精确冷读或打开会校验当前日志范围;若该行不再匹配,就从日志重新折叠,而不会把它当作权威值。实现据此承诺: -- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。 +- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保留先前行;下一次写入或精确冷读会自愈。两次写之间崩溃通常只增加尾部回放,崩溃修复则可能让保留行暂时越界,直到精确路径完成校验。 - **`ver` 与当前运行单元的 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 - **存储行必须通过当前单元的 `stateSchema`。** 畸形行从零 I/O view 中省略,并被 restore 拒绝,使冷读阶梯从日志重新折叠。 - **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。 - **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。 -- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。 +- **每次检查点写入都由日志领先。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此写入提交时缓存不可能领先日志。后续崩溃修复可能把日志截断到现有缓存行之前;精确读取会在返回权威状态前识别这种越界。 ## 写策略 diff --git a/packages/session/session-projection-cache/src/spec.ts b/packages/session/session-projection-cache/src/spec.ts index 26de5abe73..889f99fcf2 100644 --- a/packages/session/session-projection-cache/src/spec.ts +++ b/packages/session/session-projection-cache/src/spec.ts @@ -17,9 +17,12 @@ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' * One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)` * minus the two record keys). `val` is the unit's internal state — plain * JSON by the unit contract; `z.json()` enforces that at the durable - * boundary. A row is never wrong, only possibly stale: `seq` says exactly - * how stale, and a `ver` mismatch against the live unit's `stateVersion` - * discards it at read time (never a migration). + * boundary. Without reading the current log extent, `seq` identifies only the + * row's original fold cut. A zero-I/O consumer treats the row as a tentative + * hint because it may trail the log or overreach a crash-repaired truncation; + * exact restore validates the cut before using it as authoritative state. A + * `ver` mismatch against the live unit's `stateVersion` discards the row at + * read time (never a migration). */ export const checkpointRow = z.object({ ver: z.number().int().nonnegative(), @@ -59,9 +62,10 @@ export const checkpointRecord = z.object({ export type CheckpointRecord = z.infer /** - * The session-projcache domain spec. Version bumps discard the whole medium - * (cache semantics: a stale or unreadable cache costs a longer tail replay, - * never a wrong value). + * The session-projcache domain spec. Version bumps discard the whole medium. + * Zero-I/O callers may use matching rows only as tentative hints; exact reads + * validate the current log extent and refold before returning authoritative + * state. */ export const projectionCacheDomainSpec = defineDomain({ name: 'session_projcache', diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 24678d86ea..10a28f2432 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -428,10 +428,11 @@ export class SessionProjectionRegistry extends Service { /** * View a checkpoint's rows without any log read: for every registered * client-visible unit whose row's `ver` matches, serve the schema-validated - * `view` of the schema-validated stored state; mismatched, malformed, or absent rows leave their key - * absent (a cold or listing consumer treats it as not-yet-available and a - * fuller read path refolds it). The zero-I/O rung of the read ladder — - * values are as stale as their rows, never wrong. + * `view` of the schema-validated stored state; mismatched, malformed, or + * absent rows leave their key absent. The current log extent is unknown, so + * returned values are tentative hints: a row may trail the log or overreach + * a crash-repaired truncation. Exact restore validates the cut before using + * a row as authoritative state. * @param checkpoint - persisted rows for one session (possibly stale or empty). * @param keys - optional wire keys to view. * @returns whole values per key with a usable row; empty when none. From 5521b98143d595f4e1488ca2b1f6ead5c8d1541e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 10:52:27 +0800 Subject: [PATCH 21/72] fix(session-projection): isolate host state from wire snapshots --- ...ubagent-list-identity-projection.i18n.yaml | 4 +- ...08-06-subagent-list-identity-projection.md | 28 ++++++------ ...06-subagent-list-identity-projection.zh.md | 44 +++++++++---------- ...rojection-state-and-client-views.i18n.yaml | 4 +- ...ssion-projection-state-and-client-views.md | 4 +- ...on-projection-state-and-client-views.zh.md | 4 +- .../time-context/tests/time-context.spec.ts | 4 +- .../sandbox-policy/tests/policy.spec.ts | 4 +- .../tests/cache.spec.ts | 27 ++++++++++++ .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 4 +- .../session/session-projection/README.zh.md | 6 +-- .../subagent/subagent/src/list-children.ts | 2 +- .../subagent/subagent/src/projection-types.ts | 2 + packages/subagent/subagent/src/projection.ts | 1 + .../tests/continuation-inheritance.spec.ts | 2 +- .../subagent/tests/list-children.spec.ts | 8 +++- 17 files changed, 94 insertions(+), 58 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 7f3103c5c7..3380cae988 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: a4fd153f3c052b684146f1c488e3c8f12f8f7cb0 -2026-08-06-subagent-list-identity-projection.zh.md: 9680a96310c203b6bb540f7faf1eb5b89ce13d17 +2026-08-06-subagent-list-identity-projection.md: e53d918afe3415213edb0156a5f2125c73d2ae35 +2026-08-06-subagent-list-identity-projection.zh.md: 80f7583430eb5e7b7909e32e46a347224bc102ec 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 a4fd153f3c..e53d918afe 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 @@ -21,18 +21,18 @@ There are three families of escape from the per-child scan: promote mode/label i 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.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. +- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot(session, ['subagent'])` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`, using the non-null identity directly when it passes the seq gate (`seq >= seedLength ?? 0`); otherwise it pays one full Session observation 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**: live and cold snapshots both 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) 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. +- 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 adds the client-visible `subagent` identity unit and consumes it through live and cold snapshots. 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` — host-only state, while `subagentTiming` keeps the client wire view: +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`. Both units provide client wire views; the identity view is the validated state itself: ```ts ignore-check export type SubagentIdentityProjection = @@ -51,7 +51,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { - 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 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. +- 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 exposes that validated state directly as its client wire view and 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 @@ -71,8 +71,8 @@ For each enumerated child, mode/label retrieval walks a three-rung ladder — co | Rung | Read | Cost | | --- | --- | --- | -| 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 | +| 1: live child | `ctx.sessionProjections.snapshot(session, ['subagent'])` | Zero log reads — the registry's existing watermark cache, synchronous retrieval | +| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`, used directly only when the non-null 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 + a fold through the registered `subagent` unit | One full read computed per listing | - 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. @@ -122,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. -- 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. +- The list selects only the `subagent` wire unit, whose 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). 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): @@ -131,7 +131,7 @@ 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 `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. +- 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. 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). @@ -139,7 +139,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir | Area | Files | Change | | --- | --- | --- | -| subagent | projection.ts, projection-types.ts, index.ts | New host-only `subagent` unit and its registration | +| subagent | projection.ts, projection-types.ts, index.ts | New client-visible `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 | @@ -150,7 +150,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir **mode/label into SessionHeader.** The strongest zero-read guarantee — rows form from the header alone. But a header shape change propagates into both persistence backends and the header compatibility check; SQLite rejects pre-existing data outright, and JSONL pre-existing data can only degrade to unknown or be backfilled. Read-time computation's answer for pre-existing data is "one `inspect` computation on first listing", touching no durable format. -**The projection-cache ladder (`cachedSnapshot ?? coldSnapshot` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But checkpoint write-back is a whole list-driven body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); what was rejected is that orchestration as the primary mechanism. The settled three-rung ladder later reuses this cache opportunistically, read-only, as its second rung — no write-back, no orchestration, skipped when absent. +**The projection-cache ladder (`cachedSnapshot ?? cold fold` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But checkpoint write-back is a whole list-driven body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); what was rejected is that orchestration as the primary mechanism. The settled three-rung ladder later reuses this cache opportunistically, read-only, as its second rung — no write-back, no orchestration, skipped when absent. **A bounded-read primitive on persistence to rescue pre-existing data.** Opens a new persistence primitive for a one-time problem; superseded by the read-time `inspect` full read — the full read the first time pre-existing data is listed is itself the value retrieval. @@ -174,7 +174,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir - 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, 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. +- Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read use its live, cached, or observed wire snapshots, 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. @@ -182,8 +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 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 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 and consumes its live and cold wire snapshots. +- [Session projection state and client views](2026-08-19-session-projection-state-and-client-views.md) — the state/client split; both `subagent` and `subagentTiming` provide client wire views. - [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 9680a96310..80f7583430 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 @@ -10,7 +10,7 @@ Status: implemented 同一根因还有第二个症状:host 侧的 `hasSubagentDescriptor()` 在每次 Agent(智能体)绑定 RPC 的属主判定上扫描目标会话的 own suffix,即便 `SessionHeader.origin` 已经回答了同一个问题的绝大部分。 -根因在于 [durable-subagent-catalog 决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为「无索引的正确性基线」。[web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md)(#1569)已把「是不是 subagent」放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。 +根因在于 [durable-subagent-catalog 决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)把描述符事件(`subagent/descriptor`)定为目录的唯一持久权威,却没有为描述符读取配任何缓存层,并把逐 child 双读明确接受为「无索引的正确性基线」。[web subagent conversations](../feature/2026-07-27-web-subagent-conversations.zh.md)(#1569)已把「是不是 subagent」放进了 header(`SessionHeader.origin`),身份判定不再读日志;mode 与 label 仍然要扫。 ## 决策 @@ -21,18 +21,18 @@ mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠 要点: - **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。 -- **取值三级「算完即止」阶梯**: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,不存在第二份描述符解释逻辑。 +- **取值三级「算完即止」阶梯**:live child 读 `sessionProjections.snapshot(session, ['subagent'])`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`,非 null 身份通过 seq 门(`seq >= seedLength ?? 0`)即直接使用;否则执行一次完整 Session 观察,再经注册的 `subagent` unit 折叠;再没有就没有——不自建缓存、无回写、无索引。 +- **`subagent` projection unit 是折叠规则唯一权威**:live 与 cold 快照都运行同一份已注册 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 约定的权威,其后由 [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 计算,不存在第二份折叠逻辑。 +- 本记录取代 [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活约定不动。属部分取代,两记录保持交叉链接。 +- [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md) 是 registry 约定的权威,其后由 [state-and-client-views 记录](2026-08-19-session-projection-state-and-client-views.zh.md)拆分为 host 状态与客户端视图;本记录新增客户端可见的 `subagent` 身份 unit,并经 live 与 cold 快照消费它。折叠规则只在 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`——host-only 状态,`subagentTiming` 保留客户端 wire view: +挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`。两个 unit 都提供客户端 wire view;身份 view 就是校验后的状态本身: ```ts ignore-check export type SubagentIdentityProjection = @@ -51,7 +51,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { - 投影是纯身份,**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 种子回放的祖先描述符。unit 为 host-only——无客户端 wire view,其状态不进客户端 snapshot 与 `onChanged` 帧——且与其他 unit 一律检查点化(`persist` 选项已删除);`stateVersion` 现为 3:增 `seq` 升至 2,state/client 视图拆分把折叠状态改为直接的 `SubagentIdentityProjection | null` 哨兵后再升至 3。既存 checkpoint 行按 registry 约定版本失配失效、落权威重折。 +- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。unit 将这份校验后的状态直接暴露为客户端 wire view,并与其他 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 合并 @@ -71,16 +71,16 @@ declare module '@deepseek-ai/dsh-session-projection/types' { | 级 | 读法 | 成本 | | --- | --- | --- | -| 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 自身后缀,无视行水位 | 零日志读 | +| 1:live child | `ctx.sessionProjections.snapshot(session, ['subagent'])` | 零日志读——注册表既有水位缓存,同步取值 | +| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`,非 null 身份满足 `identity.seq >= header.seedLength ?? 0` 才直接使用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 | | 3:cold child,兜底 | `persistence.inspect(id)` 整读 + 经注册的 `subagent` unit 折叠 | 每次列表一次整读现算 | -- 错误约定:`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 依赖删除。 +- 错误约定:`sessionProjections` 是必需注入——`SubagentRuntime` 在 inject 集里声明它,没有 registry 的部署根本无法激活服务(与 loop),`listChildren` 不可达,而不是供出降级行([mandatory-seam 记录](2026-08-19-session-projection-mandatory-seam.zh.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` 字段。 -- 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案「算完即止」,不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 +- 冷读成本如实记录:cache 未挂载或未命中时,cold child 每次列表才付一次整读,成本与其 transcript 大小成正比;定案「算完即止」,不自建缓存。整读经 `inspect()` 走 [Session 准备阶段](2026-08-05-session-preparation.zh.md)的冷读,同 id 短期重复读取可命中其 LRU 复用,但列表不依赖此。live child 全程零日志读。 - 取消:每次 persistence 读前后检查调用方 signal,abort 之后才结算的读拒绝归一化为稳定错误码 `CANCELLED`。 ### 权威模型 @@ -122,7 +122,7 @@ export type SubagentListEntry = - `unsupported` 不再被产出:类型与 wire 枚举按「数据结构保持现状」留存该成员,本记录留档其为不再产出。 - descriptor-less 定局残骸从旧实现的 omit 归入 `corrupt` diagnostic——库里的坏、死子会话可见,不静默消失,这正是保留 diagnostic 的原始动机。 -- 列表只折叠 `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。 +- 列表只选择 `subagent` wire unit,且该折叠永不抛错:描述符损坏或版本不认识折为 null 哨兵,由四态映射收纳为该 child 的 `corrupt` 行(确定性数据故障,对齐旧实现 `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` 的映射语义)。live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是「尚无数据」,折为无值是「数据坏了」——running 的中毒 child 也出 `corrupt` 行而非 omit。 已知边界偏差(有意接受,随本记录留档): @@ -131,7 +131,7 @@ 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 `stateOf` 读取、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 snapshot、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按「cache 永不为权威」总纲归档为接受项。 消费面: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 持久日志双臂)。 @@ -139,7 +139,7 @@ export type SubagentListEntry = | 区域 | 文件 | 改动 | | --- | --- | --- | -| subagent | projection.ts、projection-types.ts、index.ts | 新 host-only `subagent` unit 与注册 | +| subagent | projection.ts、projection-types.ts、index.ts | 新客户端可见 `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、描述与渲染零改动 | @@ -150,7 +150,7 @@ export type SubagentListEntry = **mode/label 进 SessionHeader。** 零读保证最强——列表只看 header 就能成行。但 header 形状变更传导两个 persistence backend 与 header 兼容检查;SQLite 存量直接拒收,JSONL 存量只能 unknown 降级或 backfill。读时现算对存量的答案是「第一次列表一次 `inspect` 现算」,不碰持久格式。 -**projection-cache 阶梯(`cachedSnapshot ?? coldSnapshot` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过。 +**projection-cache 阶梯(`cachedSnapshot ?? cold fold` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过。 **给 persistence 加有界读原语抢救存量。** 为一次性问题新开 persistence 原语;被读时 `inspect` 整读取代——存量第一次被列表时的整读就是取值本身。 @@ -174,16 +174,16 @@ export type SubagentListEntry = - live child 的列表全程零日志读;cold child 在 cache 未挂载或未命中时每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案「算完即止」,不自建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。 - 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 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 +- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读使用其 live、cached 或 observed wire 快照,不存在手写旁路折叠;若未来某消费面绕开该 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 无兼容承诺。 ## 相关 -- [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,并经 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 整读的成本模型建立其上。 +- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。 +- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)——registry 约定的权威;本记录为其新增 `subagent` 身份 unit,并消费其 live 与 cold wire 快照。 +- [session projection 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.zh.md)——state/client 拆分;`subagent` 与 `subagentTiming` 都提供客户端 wire view。 +- [session projections 作为必需接缝](2026-08-19-session-projection-mandatory-seam.zh.md)——`sessionProjections` 转为必需注入;列表的错误约定随其变化(registry 缺席是激活期失败,投影错误码删除)。 +- [web subagent conversations](../feature/2026-07-27-web-subagent-conversations.zh.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。 +- [发布前可复用的 Session 准备阶段](2026-08-05-session-preparation.zh.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。 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 2597ed75df..6461682609 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: 0b1810b27594e7f18af5afca67ba39b0cb0f3cc3 -2026-08-19-session-projection-state-and-client-views.zh.md: 3b1ed72bf240ebf43924826d42226ff301ad8fca +2026-08-19-session-projection-state-and-client-views.md: e8b4c3c052ba3f172515a4d131b2f58f75d902d8 +2026-08-19-session-projection-state-and-client-views.zh.md: fb2488aeb6be00f981444a26baab2b5d9c8115ae 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 0b1810b275..e8b4c3c052 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,11 +12,11 @@ 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`. 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. +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. Snapshot APIs enumerate only definitions with `wire`, so omitting an audience flag cannot place internal state in an API payload. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated. ## Consequences -Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; restore rejects malformed state and the cache's existing full-read fallback rebuilds it from the log. Host consumers can replace private log scans with the same incremental fold used by carriers. +Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; restore rejects malformed state and the cache's existing full-read fallback rebuilds it from the log. Host consumers can replace private log scans with the same incremental fold used by carriers, while carrier snapshots cannot enumerate host-only state. The original [session-projection proposal](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) now records this split. The earlier [subagent identity projection](2026-08-06-subagent-list-identity-projection.md) and [projected token usage](2026-07-29-projected-token-usage-and-request-context.md) decisions remain current; their domain folds move to the state table without changing their user-facing values. 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 3b1ed72bf2..fb2488aeb6 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 @@ -12,11 +12,11 @@ `SessionProjectionStateMap` 是 host 折叠状态的 merge-extensible 类型表。每个 `ProjectionDefinition` key 都属于此表并提供 `stateSchema`;缓存行只有通过校验后才能为折叠提供初始状态。`SessionProjectionMap` 保留原有名称和语义,继续作为唯一的客户端可见全量值类型表,因此 `title: string | null` 等既有客户端数据结构保持不变。 -如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。每个单元的状态都会写入检查点——client-visible 与 host-only 一视同仁;`persist` 选择项已移除,任何单元都不能悄悄跳过持久化缓存。快照 API 只返回 `SessionProjectionMap`,因此内部状态不会进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。 +如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。每个单元的状态都会写入检查点——client-visible 与 host-only 一视同仁;`persist` 选择项已移除,任何单元都不能悄悄跳过持久化缓存。快照 API 只枚举带 `wire` 的定义,因此漏传受众标记也无法让内部状态进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。 ## 结果 -投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;恢复会拒绝畸形状态,并由缓存既有的全量读取回退从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描。 +投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;恢复会拒绝畸形状态,并由缓存既有的全量读取回退从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描,而载体快照无法枚举 host-only 状态。 原始 [session-projection 提案](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)已记录这次拆分。既有的 [subagent 身份投影](2026-08-06-subagent-list-identity-projection.zh.md)与[投影化 token 用量](2026-07-29-projected-token-usage-and-request-context.zh.md)决策仍然有效;其中的领域折叠迁入状态表,不改变面向用户的值。 diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 49d4c94335..f1c0f70d12 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -420,7 +420,7 @@ describe('time-context projection fold edges', () => { 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({ + expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ currentTurn: 1, }) }) @@ -429,7 +429,7 @@ describe('time-context projection fold edges', () => { 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({ + expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ currentTurn: null, }) }) diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 86c9fa3905..52172cb09b 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -222,10 +222,10 @@ describe('the sandbox/mode session kit', () => { 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(ctx.sessionProjections.snapshot(session).values.sandboxMode).toBeNull() + expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBeNull() setSandboxMode(session, 'workspace-write') setSandboxMode(session, 'read-only') - expect(ctx.sessionProjections.snapshot(session).values.sandboxMode).toBe('read-only') + expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBe('read-only') }) it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 9889bbbd7a..8f664e7275 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -36,6 +36,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { 'cache-test/marks': MarksState 'cache-test/marks2': Map 'cache-test/count': number + 'cache-test/secret': string } interface SessionProjectionMap { 'cache-test/marks': { marks: string[] } @@ -65,6 +66,14 @@ const marksUnit = (stateVersion = 1) => ({ stateVersion, }) satisfies ProjectionDefinition<'cache-test/marks', MarksState> +const secretUnit = { + key: 'cache-test/secret', + stateSchema: z.string(), + init: () => '', + apply: state => state, + stateVersion: 1, +} satisfies ProjectionDefinition<'cache-test/secret', string> + /** One session's record document on the per-record medium. */ const recordPath = (root: string, id: Session['id']): string => join(root, projectionCacheDomainSpec.name, 'sessions', `${String(id)}.json`) @@ -271,6 +280,24 @@ describe('SessionProjectionCache write policy', () => { }) describe('SessionProjectionCache listing read', () => { + it('keeps host-only checkpoint state out of cached wire snapshots', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) + roots.push(root) + await seedRecord(root, 'host-state', { + 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['wire'] } }, + 'cache-test/secret': { ver: 1, seq: 4, val: 'private prompt text' }, + }) + const { ctx, cache } = await harness({ root }) + ctx.sessionProjections.register(secretUnit) + const header = headerOf(SessionId('host-state')) + + expect(cache.cachedSnapshot(header)).toEqual({ + asOfSeq: 4, + values: { 'cache-test/marks': { marks: ['wire'] } }, + }) + expect(JSON.stringify(cache.cachedSnapshot(header))).not.toContain('private prompt text') + }) + it('serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-')) roots.push(root) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 050cbc6bb1..9bb0f4a26c 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: cec9f60f8a01788e6d51189934834e6a473db43e -README.zh.md: d2a42736b2dbdaddb60fdb821453d98d7c2f8e54 +README.md: 039209de357bb3849f5b8f046aafe2b1b5a6a9ff +README.zh.md: 0401471561ccf4b672210c0472ba2398391a8e88 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index cec9f60f8a..039209de35 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -11,7 +11,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence. - `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per client-visible unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`. - `ctx.sessionProjections.stateOf(session, key)` Read one registered unit's current host state without computing unrelated views. The returned value is a live read-only reference; callers must not mutate it. -- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered client-visible unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). Host-only state is available only through `stateOf`. +- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered client-visible unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). Host-only state is structurally absent and is available only through keyed state reads. ### Key Types @@ -26,7 +26,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr - **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. 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. +- **Wire snapshots and host state are separate faces.** `snapshot()`, `cachedSnapshot()`, and `viewCheckpoint()` iterate only definitions with `wire`; host code reads other state through `stateOf()`. A carrier cannot accidentally serialize every internal state by omitting a filter flag. - **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 d2a42736b2..0401471561 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -10,8 +10,8 @@ - `ctx.sessionProjections.register(definition): () => void` 注册一个领域的单元。key 重复或 `stateVersion` 非法都会 throw;注册是挂在调用方 fiber 上的 effect,领域插件卸载后其 key(连同缓存的 cell)从后续驱动与快照中消失——客户端将其读作能力缺失。 - `ctx.sessionProjections.onChanged(listener): () => void` 订阅变更流:每个已提交事件、每个状态引用发生变化的客户端可见单元各回调一次,携带经 schema 校验的 view 与致因 seq。与 `register` 一样绑定 effect。 -- `ctx.sessionProjections.stateOf(session, key)` 读取一个已注册单元的当前 host 状态,不计算无关 view。返回值是活的只读引用;调用方不得修改。 -- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` 对全部已注册客户端可见单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`)。host-only 状态只能通过 `stateOf` 读取。 +- `ctx.sessionProjections.stateOf(session, key)` 读取一个已注册单元的当前 host 状态,不计算无关 view。返回的是在线只读引用;调用方不得修改。 +- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` 对全部已注册客户端可见单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`)。host-only 状态在结构上缺席,只能通过按 key 的状态读取面取得。 ### 关键类型 @@ -26,7 +26,7 @@ - **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 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 一视同仁。 -- **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 +- **wire 快照与 host 状态是分离的读取面。** `snapshot()`、`cachedSnapshot()` 与 `viewCheckpoint()` 只遍历带 `wire` 的定义;host 代码通过 `stateOf()` 读取其他状态。载体无法因漏传过滤标记而意外序列化全部内部状态。 - **单元与 host 读取方必需。** 贡献或读取投影单元的插件注入 `sessionProjections`,因此不完整的组合会在激活时失败。较低层的 api-proxy factory 仍对隔离测试和诊断保持容错,注册表缺席时省略投影块与帧。 ## 职责 diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index ffefa624b8..afa6701367 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -119,7 +119,7 @@ interface PositionedCandidate { * 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 - * projection-cache row when it serves an own-suffix identity (the seq gate), + * projection-cache read when it serves an own-suffix identity (the seq gate), * else one bounded-concurrency shared Session observation. * @see SubagentRuntime.listChildren for the public cancellation and failure contract. * @param ctx - context carrying the session store, the projection registry, diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index b07ec8e8a2..6eebb78a52 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -60,6 +60,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' { subagent: SubagentIdentityProjection | null } interface SessionProjectionMap { + /** Durable mode and label for a descriptor-backed subagent session. */ + subagent: SubagentIdentityProjection | null /** 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 ab510541a6..49ee4af239 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -166,4 +166,5 @@ export const subagentIdentityProjectionDefinition = { const identity = descriptorIdentity(event) return identity === undefined ? null : identity }, + wire: { viewSchema: identitySchema, view: state => state }, } 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 dda18898f9..8c975b57fc 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -78,7 +78,7 @@ function policyEvents(events: readonly SessionEvent[]) { } function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { - return ctx.sessionProjections.snapshot(Session.create(id, events)).values.sandboxMode + return ctx.sessionProjections.stateOf(Session.create(id, events), 'sandboxMode') } function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 4d6ed421b2..31109f1396 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -67,7 +67,13 @@ 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' }) + const loop = ctx.get('agentLoop') + const parent = loop === undefined + ? (() => { + const session = ctx.sessions.create(SessionId('parent')) + return { id: session.id, session } as ReturnType + })() + : loop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } From cf06f10229129d2f32c71f3462d8ecb920d55ccb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 11:32:19 +0800 Subject: [PATCH 22/72] test(session-controller): cover cold host-only projection cache --- packages/api/session-controller/package.json | 3 + .../tests/session-projections.host.spec.ts | 72 +++++++++++++++++++ pnpm-lock.yaml | 9 +++ 3 files changed, 84 insertions(+) diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index dabfd8fd6a..357090e0b6 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -127,6 +127,9 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index fd30f66158..43391af21f 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -8,6 +8,9 @@ */ import { describe, expect, it, vi } from 'vitest' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' @@ -19,6 +22,10 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' import { SessionControlController } from '@deepseek-ai/dsh-api-session-controller/src/control.ts' import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts' @@ -27,6 +34,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { 'test/last-user': LastUserState 'test/internal-count': number + 'test/private-prompt': string | null } interface SessionProjectionMap { 'test/last-user': { text: string } | null @@ -91,6 +99,16 @@ const internalCountUnit = () => ({ stateVersion: 1, }) satisfies ProjectionDefinition<'test/internal-count', number> +const privatePromptUnit = () => ({ + key: 'test/private-prompt', + stateSchema: z.string().nullable(), + init: () => null, + apply: (state, event) => (event.type === 'user/message' + ? (event.data.content[0] as { text?: string }).text ?? '' + : state), + stateVersion: 1, +}) satisfies ProjectionDefinition<'test/private-prompt', string | null> + async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -420,6 +438,60 @@ describe('session.list projections column', () => { }) }) + it('keeps persisted host-only state out of a cold session.list response', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-api-projcache-')) + const ctx = new Context() + try { + await ctx.plugin(Storage) + await ctx.plugin(StorageJson, { root }) + await ctx.plugin(StorageDomain, { backend: 'json' }) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(privatePromptUnit()) + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + const gateway = remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + + const id = SessionId('session-cold-host-state') + const secret = 'private prompt text from the cache' + let session: Session | undefined + const owner = await ctx.plugin(Object.assign((sessionCtx: Context) => { + session = sessionCtx.sessions.create(id, { meta: { createdAt: 5, cwd: '/workspace' } }) + }, { inject: ['sessions'] })) + if (session === undefined) throw new Error('session was not created') + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: secret }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await ctx.sessionProjectionCache.write(session) + const stored = await readFile( + join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`), + 'utf8', + ) + expect(stored).toContain(secret) + + const header = session.header + await owner.dispose() + expect(ctx.sessions.get(id)).toBeUndefined() + ctx.provide('sessionPersistence', { + list: async () => [header], + locate: () => undefined, + } as never) + + const response = await gateway.list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === id) + expect(row?.projections?.values.sessionListMetadata).toMatchObject({ blank: false }) + expect('test/private-prompt' in (row?.projections?.values ?? {})).toBe(false) + expect(JSON.stringify(row)).not.toContain(secret) + } finally { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } + }) + it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => { const { ctx } = await harness(true) const coldId = SessionId('session-cold-uncached') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fef9eb248..368df9b58d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -798,6 +798,15 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../storage/storage-json '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent From 3d05fdfbfbd062d61b713147d418be6080572ac2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 11:32:38 +0800 Subject: [PATCH 23/72] refactor(session): keep instruction and skill scans on event log --- .../context/agent-instructions/package.json | 6 +- .../context/agent-instructions/src/index.ts | 7 - .../context/agent-instructions/src/render.ts | 2 +- .../context/agent-instructions/src/state.ts | 62 +------ .../tests/agent-instructions.spec.ts | 173 +++++------------- packages/skill/tool-skill/package.json | 9 +- packages/skill/tool-skill/src/index.ts | 63 ++----- .../skill/tool-skill/tests/tool-skill.spec.ts | 4 - pnpm-lock.yaml | 9 - 9 files changed, 77 insertions(+), 258 deletions(-) diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index e1d555cb0a..255ee9e720 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -39,12 +39,10 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index 25b35a4169..ab68bce9d0 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -15,13 +15,11 @@ 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, @@ -79,12 +77,7 @@ 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 { - /** Newest-first workspace-instruction change history 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) { - const record = { change, seq: event.seq } - const history = next[change.scope] - next = { ...next, [change.scope]: history === undefined ? [record] : [record, ...history] } - } - return next - }, - stateVersion: 2, - } -} - function visibleInstructionChanges( agent: Agent, authorityMessages: readonly UserMessage[], ): Map { const visibleSeqs = new Set(agent.session.surface.nodes) 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, 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 [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) + } } 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 86052dc379..11d385106e 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -37,7 +37,6 @@ import { import { applyInstructionVersionUpdates, baselineInstructionState, - createWorkspaceInstructionsProjection, reconcileInstructionContext, type InstructionVersionCache, } from '../src/state.ts' @@ -169,16 +168,9 @@ 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 pluginWorkspaceContext(ctx, config) + return ctx.plugin(workspaceContext, config) } async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { @@ -186,17 +178,14 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace await ctx.plugin(ToolRuntime) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - return pluginWorkspaceContext(ctx, config) + return ctx.plugin(workspaceContext, 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, + ctx: new Context(), id: SessionId('a1'), options: {}, session, @@ -205,7 +194,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { send: () => {}, followup: () => {}, steer: () => {}, - inject: () => { throw new Error('workspace-context must append directly to the open step') }, + inject: () => { throw new Error('agent-instructions must append directly to the open step') }, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), @@ -634,10 +623,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 })) @@ -997,27 +986,26 @@ describe('workspace context request injection', () => { it('requires an explicit maxBytes configuration', async () => { const ctx = new Context() - await expect(pluginWorkspaceContext(ctx, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) + await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/) }) it('mounts without requiring a filesystem provider', async () => { const ctx = new Context() try { - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) } finally { await ctx.fiber.dispose() } }) it('does not declare fs as a static inject dependency', () => { - expect(workspaceContext.inject).toEqual(['sessionProjections']) - expect(workspaceContext.inject).not.toContain('fs') + expect('inject' in workspaceContext).toBe(false) }) it('does not inject baseline context when no filesystem provider is present', async () => { const ctx = new Context() try { - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent('/virtual/repo') await composeBaselinePrefix(ctx, agent) @@ -1124,7 +1112,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const original = stubAgent(root) await composeBaselinePrefix(ctx, original) @@ -1368,7 +1356,7 @@ describe('workspace context request injection', () => { expect(inserted?.source).toMatchObject({ kind: 'agent-instructions', baseline: true }) await fiber.dispose() - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { 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) @@ -1414,7 +1402,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { 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) @@ -1467,7 +1455,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) - await pluginWorkspaceContext(resumedCtx, { dshHome: home, maxBytes }) + await resumedCtx.plugin(workspaceContext, { 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) @@ -1577,7 +1565,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(10_000) }, + { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) }, () => Promise.resolve(downstream), ) @@ -1662,7 +1650,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(1) @@ -1692,7 +1680,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) const baseline = baselineEvents(agent)[0] @@ -1707,7 +1695,7 @@ describe('workspace context request injection', () => { }) await fiber.dispose() - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(2) @@ -1994,7 +1982,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2017,7 +2005,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2059,7 +2047,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2084,7 +2072,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2107,7 +2095,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel prefix') const pending = agentEvents(ctx, stubAgent(root)).waterfall( @@ -2141,7 +2129,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2167,7 +2155,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2190,7 +2178,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2213,7 +2201,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2235,7 +2223,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2260,7 +2248,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2310,7 +2298,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 pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent(cwd) await composeBaselinePrefix(ctx, agent) @@ -2331,7 +2319,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 pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2527,7 +2515,8 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(SessionProjectionRegistry) 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 }) @@ -2603,7 +2592,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel dynamic reconciliation') controller.abort(reason) @@ -2866,7 +2855,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -2903,7 +2892,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await ctx.tools.execute({ @@ -2948,7 +2937,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const firstAgent = stubAgent(root) const secondAgent = stubAgent(root) @@ -3484,7 +3473,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -3626,72 +3615,6 @@ 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() @@ -3929,7 +3852,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const result = await ctx.tools.execute({ @@ -4070,7 +3993,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer policy block' }] } : downstream }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4139,7 +4062,7 @@ describe('dynamic nested workspace context injection', () => { ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer composite block' }] } : downstream }) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4163,7 +4086,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { 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' }) @@ -4238,7 +4161,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4261,7 +4184,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const agent = stubAgent('/') const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null } @@ -4310,7 +4233,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await pluginWorkspaceContext(ctx, { maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const root = resolve('/') const agent = stubAgent(root) @@ -4376,7 +4299,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 20 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -4498,7 +4421,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 1 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 1 }) const agent = stubAgent(root) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4574,7 +4497,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, @@ -4614,7 +4537,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 pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) + await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 36ef65599f..798c09ca05 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -37,12 +37,10 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -53,7 +51,6 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 72af2f7006..222e8a0ace 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -7,12 +7,10 @@ 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, @@ -24,7 +22,7 @@ import { } from '@deepseek-ai/dsh-skill' export const name = 'tool-skill' -export const inject = ['agents', 'tools', 'skills', 'sessionProjections'] +export const inject = ['agents', 'tools', 'skills'] const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500 /** @@ -70,30 +68,12 @@ 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.array(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 { - /** Newest-first published skill-catalog messages (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) @@ -223,20 +203,6 @@ export function apply(ctx: Context, config: Config = {}): void { return { ...decision, messages: [...decision.messages, ...injections] } }) - ctx.sessionProjections.register({ - key: 'skillCatalog', - 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 - const record = { digest: digestCatalogEntries(entries), seq: event.seq } - return state === null ? [record] : [record, ...state] - }, - }) - // Register after the tool so reverse teardown removes guidance first. Exact definition // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. // @@ -260,7 +226,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(ctx, agent) + const history = catalogHistory(agent) const existing = catalogMessage(decision.messages) if (history.visibleDigest === digest) { return existing === undefined @@ -392,17 +358,22 @@ function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | un return readable } -function catalogHistory(ctx: Context, agent: Agent): { visibleDigest?: string; published: boolean } { +function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { const visible = new Set(agent.session.surface.nodes) - const state = ctx.sessionProjections.stateOf(agent.session, 'skillCatalog') ?? null - if (state === null) return { published: false } - // 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 } + 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 } } 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 4b63ba8207..d8dd9df2f4 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -9,7 +9,6 @@ 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' @@ -31,7 +30,6 @@ 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 }) @@ -754,7 +751,6 @@ 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/pnpm-lock.yaml b/pnpm-lock.yaml index 368df9b58d..18a33e8793 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3879,9 +3879,6 @@ 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:^ @@ -8039,9 +8036,6 @@ 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:^ @@ -8061,9 +8055,6 @@ 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 From c7abeb23bfed380d2663145c9225d363dedb52ca Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 11:59:28 +0800 Subject: [PATCH 24/72] refactor(session): keep approval outside projection migration --- docs/subsystems/approval.i18n.yaml | 4 +- docs/subsystems/approval.md | 24 +++--- docs/subsystems/approval.zh.md | 26 +++--- .../interaction/user-approval/package.json | 10 +-- .../user-approval/tests/approval.spec.ts | 84 ++----------------- .../interaction/user-approval/tsconfig.json | 3 - .../tests/continuation-inheritance.spec.ts | 2 +- pnpm-lock.yaml | 9 -- 8 files changed, 39 insertions(+), 123 deletions(-) diff --git a/docs/subsystems/approval.i18n.yaml b/docs/subsystems/approval.i18n.yaml index 08495f66b2..e70b9747e5 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: ee8ab1cfca1fba6868b5f4931bab3dd9118a7a9b -approval.zh.md: f84d6a1bcd44e565a5cce85fbeb2a1194a2acade +approval.md: d9f1169b52e427cd37e7bc54fa37da59d48aecce +approval.zh.md: abc2361db3d84517c7e7497cfb39b4548160c259 diff --git a/docs/subsystems/approval.md b/docs/subsystems/approval.md index ee8ab1cfca..d9f1169b52 100644 --- a/docs/subsystems/approval.md +++ b/docs/subsystems/approval.md @@ -30,7 +30,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' ## Per-session policy -`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override. +`ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. Consumers read it with `ctx.approval.effectivePolicy(session)`; `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override. ```ts type-equiv /** @@ -57,7 +57,7 @@ Both policies contribute their complete current meaning to the cache-safe runtim * Readonly same-process permission question. `callId` links to an already * presented tool call, so arguments are not duplicated here. */ -interface ApprovalRequest { +interface ApprovalRequest extends ApprovalRequestEvent { /** * The agent on whose behalf the question is asked. Routes the question (a * UI answerer only answers for agents it owns) and receives the audit @@ -93,7 +93,7 @@ The audit events are log-only and do not enter the model transcript. Model-visib ## Cordis API -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). @@ -132,7 +132,7 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void async request(req: ApprovalRequest): Promise /** - * Read the projected session override without applying the configured default. + * Read the 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:190`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts) @@ -151,20 +151,20 @@ Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/i #### `approval/request` — waterfall -Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Ask composed answerers for one decision. Return an outcome to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog /** * Ask composed answerers for one decision. Return an outcome to claim the - * request or call `next()`; failure yields the fail-closed default. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param req - the pending decision (agent, tool identity, reason, signal). + * request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - pending approval request. * @mode waterfall */ -'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise +'approval/request'( this: Scoped, req: ApprovalRequestEvent, next: () => Promise, ): Promise ``` -Types: [Scoped](scope.md) +Types: [Agent](core.md) · [Scoped](scope.md) -Source: [`packages/interaction/user-approval/src/index.ts:32`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/types.ts`](../../packages/interaction/user-approval/src/types.ts) diff --git a/docs/subsystems/approval.zh.md b/docs/subsystems/approval.zh.md index f84d6a1bcd..abc2361db3 100644 --- a/docs/subsystems/approval.zh.md +++ b/docs/subsystems/approval.zh.md @@ -30,7 +30,7 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' ## 按会话策略 -`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。 +`ApprovalPolicy` 决定在交互式应答者运行之前发生什么。`ask` 委托给组合的应答者链,链的无应答默认值为 `unavailable`;`never` 确定性地返回 `rejected`,不分发任何应答者。生效值为会话日志中最后一条 `approval/policy` 事件,回退到服务配置。消费方通过 `ctx.approval.effectivePolicy(session)` 读取;`setApprovalPolicy(session, policy)` 是唯一的写入路径,因此回放能重建覆盖值。 ```ts type-equiv /** @@ -57,7 +57,7 @@ type ApprovalPolicy = 'ask' | 'never' * Readonly same-process permission question. `callId` links to an already * presented tool call, so arguments are not duplicated here. */ -interface ApprovalRequest { +interface ApprovalRequest extends ApprovalRequestEvent { /** * The agent on whose behalf the question is asked. Routes the question (a * UI answerer only answers for agents it owns) and receives the audit @@ -93,7 +93,7 @@ interface ApprovalRequest { ## Cordis API -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). @@ -132,16 +132,16 @@ setPolicy(agent: Agent, policy: ApprovalPolicy): void async request(req: ApprovalRequest): Promise /** - * Read the projected session override without applying the configured default. + * Read the 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 ``` -Types: [Agent](core.md) · [Session](session.md) +Types: [Agent](core.zh.md) · [Session](session.zh.md) -Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts`](../../packages/interaction/user-approval/src/index.ts) @@ -151,20 +151,20 @@ Source: [`packages/interaction/user-approval/src/index.ts:190`](../../packages/i #### `approval/request` — waterfall -Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Ask composed answerers for one decision. Return an outcome to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog /** * Ask composed answerers for one decision. Return an outcome to claim the - * request or call `next()`; failure yields the fail-closed default. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param req - the pending decision (agent, tool identity, reason, signal). + * request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - pending approval request. * @mode waterfall */ -'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise +'approval/request'( this: Scoped, req: ApprovalRequestEvent, next: () => Promise, ): Promise ``` -Types: [Scoped](scope.md) +Types: [Agent](core.zh.md) · [Scoped](scope.zh.md) -Source: [`packages/interaction/user-approval/src/index.ts:32`](../../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/types.ts`](../../packages/interaction/user-approval/src/types.ts) diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index d94977e09e..5ec59a32d6 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -44,12 +44,10 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "zod": "^4.4.3" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -59,8 +57,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index 906551f229..3271ecc7a5 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -6,14 +6,8 @@ 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, ApprovalRequestId, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' - -function registerTurnBoundary(ctx: Context): void { - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) -} +import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' /** * A minimal Agent stand-in — the service only reaches `agent.session.append` @@ -21,10 +15,7 @@ function registerTurnBoundary(ctx: Context): void { * turn-enclosure precondition); pass `seed` to stage idle/closed logs. * Returns the recorded audit appends alongside the fake. */ -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 }> } { +function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record }> } { const appended: Array<{ type: string; data: Record }> = [] const agent = { session: { @@ -40,8 +31,6 @@ function fakeAgent(seed: Array<{ type: string; data?: Record; s async function mounted(): Promise { const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) await ctx.plugin(ApprovalService) return ctx } @@ -61,10 +50,7 @@ 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', seq: 0, data: { turn: 1 } }, - { type: 'turn/end', seq: 1, data: { turn: 1, reason: { kind: 'completed' } } }, - ]) + const { agent, appended } = fakeAgent([{ type: 'turn/start' }, { type: 'turn/end' }]) await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/) expect(appended).toHaveLength(0) @@ -130,8 +116,6 @@ 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 }) @@ -155,8 +139,6 @@ 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 }) @@ -182,7 +164,7 @@ describe('ApprovalService.request', () => { const failure = new Error('append failed before log growth') const agent = { session: { - events: [{ type: 'turn/start', seq: 0, data: { turn: 1 } }], + events: [{ type: 'turn/start' }], append: () => { throw failure }, }, } as unknown as Agent @@ -382,45 +364,15 @@ describe('approval policy (the approval/policy fold)', () => { return { agent, session } } - it('folds to the last event, or undefined without one', async () => { - const ctx = await mounted() + it('folds to the last event, or undefined without one', () => { const { session } = sessionAgent('sess-fold') - expect(ctx.approval.overrideOf(session)).toBeUndefined() + expect(effectiveApprovalPolicy(session.events)).toBeUndefined() setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') - expect(ctx.approval.overrideOf(session)).toBe('ask') + expect(effectiveApprovalPolicy(session.events)).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 @@ -434,8 +386,6 @@ 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')) @@ -444,8 +394,6 @@ 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') }) @@ -454,8 +402,6 @@ 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() }) @@ -470,8 +416,6 @@ 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') @@ -482,8 +426,6 @@ 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 }) @@ -495,8 +437,6 @@ 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') @@ -510,8 +450,6 @@ 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() @@ -520,7 +458,7 @@ describe('approval policy (the approval/policy fold)', () => { ctx.approval.setPolicy(liveAgent, 'never') ctx.approval.setPolicy(liveAgent, 'never') - expect(ctx.approval.overrideOf(session)).toBe('never') + expect(effectiveApprovalPolicy(session.events)).toBe('never') expect(inject).toHaveBeenCalledOnce() expect(inject.mock.calls[0]?.[0]).toMatchObject({ content: [{ @@ -534,8 +472,6 @@ 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') @@ -551,8 +487,6 @@ 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 () => @@ -569,8 +503,6 @@ 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 7adfb948c7..2c8d26e1f6 100644 --- a/packages/interaction/user-approval/tsconfig.json +++ b/packages/interaction/user-approval/tsconfig.json @@ -37,9 +37,6 @@ }, { "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../session/session-projection" } ] } diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index 8c975b57fc..97c7737ad7 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -82,7 +82,7 @@ function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly Session } function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { - return ctx.sessionProjections.snapshot(Session.create(id, events)).values.approvalPolicy + return ctx.approval.overrideOf(Session.create(id, events)) } describe('continuable policy inheritance', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bca6baead..96fb5a7d31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6110,9 +6110,6 @@ 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:^ @@ -6120,9 +6117,6 @@ 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 @@ -6138,9 +6132,6 @@ 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 From 3759ea5dfe998cb07d6c7b8c2ff883015ce2b576 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 12:00:02 +0800 Subject: [PATCH 25/72] fix(agent-team): keep projection through runtime disposal --- packages/experimental/agent-team/src/index.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index 690247ec45..de0487e93b 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -34,7 +34,6 @@ export type * from './types.ts' export type { TeamMembership } from './roster.ts' export { TeamId, TeamMessageId, TeamTaskId } from './types.ts' export { TeamError } from './error.ts' -export { foldTeam } from './fold.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -114,10 +113,16 @@ export class TeamService extends TypertRemoteService { const membership = this.roster.tryMembership(agent) if (membership !== undefined) this.activity.notify(membership.id) }) - ctx.effect(function* (this: TeamService) { - yield ctx.sessionProjections.register(teamProjectionDefinition) - yield () => this.disposeRuntime() - }.bind(this), 'agentTeams.runtimeLifecycle()') + ctx.effect(() => { + const disposeProjection = ctx.root.sessionProjections.register(teamProjectionDefinition) + return async () => { + try { + await this.disposeRuntime() + } finally { + disposeProjection() + } + } + }, 'agentTeams.runtimeLifecycle()') for (const agent of ctx.agents.list()) this.scheduleRecovery(agent) } From 212df86cf896ab650146d14ce4f6306aa8d78c7d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 13:23:01 +0800 Subject: [PATCH 26/72] refactor(session): migrate simple folds to projections --- docs/config-catalog.md | 64 ++-- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 360 +++++++++--------- docs/persistence-catalog.md | 16 +- .../context/agent-instructions/package.json | 1 + .../context/agent-instructions/src/index.ts | 27 +- .../tests/agent-instructions.spec.ts | 97 ++--- .../context/agent-instructions/tsconfig.json | 3 + packages/context/time-context/src/index.ts | 25 +- .../time-context/tests/time-context.spec.ts | 16 +- packages/core/agent-loop/src/index.ts | 6 +- packages/core/agent/src/types.ts | 2 - .../experimental/agent-team/src/journal.ts | 7 +- .../experimental/agent-team/src/projection.ts | 40 +- .../agent-team/tests/persistence.spec.ts | 9 +- .../tests/projection-events.spec.ts | 33 +- .../agent-team/tests/team.spec.ts | 9 +- packages/goal/goal/src/index.ts | 135 ++++--- packages/goal/goal/src/types.ts | 12 +- packages/goal/goal/tests/projection.spec.ts | 67 ++-- packages/plan/plan-mode/src/index.ts | 6 +- packages/plan/plan-mode/src/types.ts | 4 +- packages/preset/agent-presets/src/index.ts | 12 +- .../agent-presets/tests/authoring.spec.ts | 17 +- .../preset/agent-presets/tests/mount.spec.ts | 1 + .../preset/agent-presets/tests/remote.spec.ts | 2 + .../agent-presets/tests/settings.spec.ts | 1 - .../agent-presets/tests/shipped-root.spec.ts | 2 + .../agent-presets/tests/user-root.spec.ts | 2 + packages/session/session-title/src/index.ts | 36 +- packages/session/session-title/src/types.ts | 4 +- .../session-title/tests/projection.spec.ts | 16 +- packages/subagent/tool-subagent/package.json | 4 +- packages/subagent/tool-subagent/src/index.ts | 16 +- .../subagent/tool-subagent/src/invariant.ts | 6 +- .../src/model-selection-state.ts | 31 +- .../subagent/tool-subagent/tests/harness.ts | 2 + .../tool-subagent/tests/list-models.spec.ts | 8 + .../tests/model-selection-settings.spec.ts | 14 +- .../tests/model-selection.spec.ts | 3 + .../tool-subagent/tests/tool-subagent.spec.ts | 70 ++-- packages/subagent/tool-subagent/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 43 files changed, 677 insertions(+), 519 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7483499490..a9ffef7fe6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -53,6 +53,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 { @@ -83,7 +85,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. */ @@ -109,13 +111,13 @@ 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:310`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-presets` -Requires: `loader` +Requires: `loader` · `sessionProjections` ```ts config-catalog /** Plugin config: which preset is the default, and where presets live. */ @@ -248,7 +250,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:93`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:94`](../packages/examples/agent-spine-demo/src/index.ts) @@ -576,7 +578,7 @@ Source: [`packages/e2b/e2b/src/index.ts:43`](../packages/e2b/e2b/src/index.ts) ## `@deepseek-ai/dsh-experimental-agent-team` -Requires: `agents` · `sessions` · `sessionPersistence` · `subagents` +Requires: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` ```ts config-catalog /** Team-service deployment limits. */ @@ -677,7 +679,7 @@ Source: [`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src ## `@deepseek-ai/dsh-goal` -Requires: `agents` +Requires: `agents` · `sessionProjections` ```ts config-catalog /** Deployment defaults for goal creation. */ @@ -687,7 +689,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:171`](../packages/goal/goal/src/index.ts) @@ -709,7 +711,7 @@ Source: [`packages/bundle/headless/src/index.ts:32`](../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. */ @@ -741,13 +743,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. */ @@ -768,7 +770,7 @@ 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) @@ -1313,14 +1315,14 @@ Source: [`packages/test-support/llm-replay/src/index.ts:914`](../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) @@ -1457,7 +1459,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. */ @@ -1490,7 +1492,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:112`](../packages/interaction/permission-presets/src/index.ts) @@ -1520,7 +1522,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. */ @@ -1530,7 +1532,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) @@ -1673,6 +1675,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` @@ -1694,7 +1698,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) @@ -1958,7 +1962,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. */ @@ -1972,7 +1976,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:53`](../packages/session/session-title/src/index.ts) @@ -2479,7 +2483,7 @@ Source: [`packages/core/system-prompt/src/index.ts:237`](../packages/core/system ## `@deepseek-ai/dsh-terminal-bash` -Requires: `terminals` · `sandboxPolicy` · `subprocess` +Requires: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` ```ts config-catalog /** Public plugin configuration. */ @@ -2529,7 +2533,7 @@ Source: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/termina ## `@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. */ @@ -2541,13 +2545,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:48`](../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. */ @@ -2557,12 +2561,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 @@ -2669,7 +2675,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. */ @@ -2799,7 +2805,7 @@ Source: [`packages/workflow/tool-ralph/src/index.ts:22`](../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. */ @@ -2851,7 +2857,7 @@ Source: [`packages/fs/tool-str-replace-editor/src/index.ts:505`](../packages/fs/ ## `@deepseek-ai/dsh-tool-subagent` -Requires: `tools` · `subagents` · `systemPrompt` +Requires: `tools` · `subagents` · `systemPrompt` · `sessionProjections` ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -2918,7 +2924,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:45`](../packages/subagent/tool-subagent/src/index.ts) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ff4a530f60..a3c8ae5ae4 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:238`](../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:92`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -77,7 +77,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index f2c8a499fc..0ff087dd2a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -461,12 +461,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 @@ -506,43 +500,19 @@ flowchart TD pkg_session_projection --> pkg_session pkg_session_snapshot --> pkg_invariants pkg_session_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_file_reference --> pkg_agent - pkg_file_reference --> pkg_invariants - pkg_file_reference --> pkg_typert_protocol - 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 @@ -550,38 +520,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_user_questions --> pkg_scope - pkg_jobs --> pkg_agent - pkg_jobs --> pkg_brand - pkg_jobs --> pkg_invariants - pkg_jobs --> pkg_session 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 @@ -597,30 +539,10 @@ flowchart TD pkg_session_stats --> pkg_llm pkg_session_stats --> pkg_session pkg_session_stats --> pkg_session_projection - pkg_session_telemetry --> pkg_agent - pkg_session_telemetry --> pkg_invariants - pkg_session_telemetry --> pkg_session - 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_terminal --> pkg_agent - pkg_terminal --> pkg_brand - pkg_terminal --> pkg_invariants - pkg_loader_smoke --> pkg_agent - pkg_loader_smoke --> pkg_invariants - pkg_loader_smoke --> pkg_llm - pkg_loader_smoke --> pkg_session - pkg_workflow --> pkg_agent - pkg_workflow --> pkg_brand - pkg_workflow --> pkg_invariants - pkg_workflow --> pkg_llm - pkg_workflow --> pkg_session pkg_workspace --> pkg_brand pkg_workspace --> pkg_invariants pkg_workspace --> pkg_session @@ -649,6 +571,125 @@ flowchart TD pkg_llm_pi_ai --> pkg_llm pkg_llm_pi_ai --> pkg_settings pkg_llm_pi_ai --> pkg_timeout + 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_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants + pkg_file_reference --> pkg_typert_protocol + 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_system_prompt + pkg_user_questions --> pkg_agent + pkg_user_questions --> pkg_invariants + pkg_user_questions --> pkg_llm + pkg_user_questions --> pkg_scope + 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_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_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 + pkg_loader_smoke --> pkg_agent + pkg_loader_smoke --> pkg_invariants + pkg_loader_smoke --> pkg_llm + pkg_loader_smoke --> pkg_session + pkg_workflow --> pkg_agent + pkg_workflow --> pkg_brand + pkg_workflow --> pkg_invariants + pkg_workflow --> pkg_llm + pkg_workflow --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_invariants @@ -666,17 +707,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_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -687,13 +722,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 @@ -713,33 +741,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 @@ -754,6 +776,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 @@ -764,13 +787,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 @@ -831,6 +850,7 @@ 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_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction @@ -841,6 +861,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_file_reference_local --> pkg_agent pkg_file_reference_local --> pkg_file_reference @@ -926,16 +947,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 @@ -1071,9 +1082,11 @@ flowchart TD 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 @@ -1166,6 +1179,7 @@ flowchart TD pkg_tool_subagent --> pkg_llm pkg_tool_subagent --> pkg_scope pkg_tool_subagent --> pkg_session + pkg_tool_subagent --> pkg_session_projection pkg_tool_subagent --> pkg_settings pkg_tool_subagent --> pkg_subagent pkg_tool_subagent --> pkg_system_prompt @@ -1186,6 +1200,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_api_gateway --> pkg_brand @@ -1199,6 +1214,7 @@ flowchart TD pkg_experimental_agent_team --> pkg_llm pkg_experimental_agent_team --> pkg_session pkg_experimental_agent_team --> pkg_session_persistence + pkg_experimental_agent_team --> pkg_session_projection pkg_experimental_agent_team --> pkg_subagent pkg_experimental_agent_team --> pkg_typert_protocol pkg_host_frontend_static --> pkg_client_connection @@ -1738,7 +1754,6 @@ flowchart TD | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`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) | @@ -1752,58 +1767,61 @@ 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) | | [`session-snapshot`](../packages/test-support/session-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) | -| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`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), [`scope`](../packages/core/scope) | -| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`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), [`llm`](../packages/llm/llm), [`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-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) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`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) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | +| [`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), [`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), [`scope`](../packages/core/scope) | +| [`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) | +| [`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) | | [`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), [`llm-retry`](../packages/llm/llm-retry), [`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) | @@ -1812,9 +1830,9 @@ flowchart TD | [`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) | | [`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) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`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) | @@ -1829,8 +1847,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-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1850,7 +1866,7 @@ flowchart TD | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`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), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`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) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`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), [`typert-protocol`](../packages/typert/protocol) | @@ -1861,12 +1877,12 @@ flowchart TD | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`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) | | [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`experimental-agent-team`](../packages/experimental/agent-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), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-agent-team`](../packages/experimental/agent-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), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`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-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 593fb07302..7956999d8d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -104,7 +104,7 @@ Sources: [`packages/core/session/src/types.ts:328`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:38`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:57`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -513,13 +513,13 @@ Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/sess /** * 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/*` @@ -531,12 +531,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/*` @@ -584,7 +584,7 @@ Source: [`packages/core/session/src/types.ts:291`](../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': { @@ -662,7 +662,7 @@ Source: [`packages/core/session/src/types.ts:324`](../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:74`](../packages/session/session-title/src/index.ts) @@ -752,7 +752,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent 'subagent/model-selection-enabled': Record ``` -Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 255ee9e720..1f1202afce 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index ab68bce9d0..ace82c24b2 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -14,6 +14,7 @@ import { isDeepStrictEqual } from 'node:util' 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 {} from '@deepseek-ai/dsh-session-projection' import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts' import { findProjectRoot, loadBaselineInstructionSet } from './files.ts' @@ -29,6 +30,8 @@ import { import type { AgentInstructionChange } from './render.ts' export { Config, name } +/** Services required by workspace instruction projection. */ +export const inject = ['sessionProjections'] export { discoverBaselineInstructionFiles, loadBaselineInstructions, @@ -99,7 +102,6 @@ export function apply(ctx: Context, config: Config): void { const projectionTails = new WeakMap>() // Execution ancestry and the enclosing durable step are the two commit // boundaries before an asynchronous projection may mutate the agent inbox. - const openSteps = new WeakMap() const stepTouches = new WeakMap() const compose = async ( @@ -280,15 +282,13 @@ export function apply(ctx: Context, config: Config): void { } const stepIsOpen = (session: Session): boolean => { - const known = openSteps.get(session) - if (known !== undefined) return known - let open = false - for (const event of session.events) { - if (event.type === 'step/start') open = true - else if (event.type === 'step/end' || event.type === 'turn/end') open = false + const boundary = ctx.sessionProjections.stateOf(session, 'turnBoundary') + if (boundary === undefined) { + throw new Error('agent-instructions requires the turnBoundary session projection') } - openSteps.set(session, open) - return open + return boundary.openTurnStartSeq !== null + && boundary.lastStepBoundary?.kind === 'start' + && boundary.lastStepBoundary.seq > boundary.openTurnStartSeq } const projectTouch = (touch: ProjectionTouch): void => { @@ -303,16 +303,7 @@ export function apply(ctx: Context, config: Config): void { } ctx.on('session/event', (session, event) => { - if (event.type === 'step/start') { - openSteps.set(session, true) - return - } - if (event.type === 'turn/end') { - openSteps.set(session, false) - return - } if (event.type !== 'step/end') return - openSteps.set(session, false) const pending = stepTouches.get(session) if (pending === undefined) return stepTouches.delete(session) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 11d385106e..c73280f87f 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -9,7 +9,7 @@ import LlmRuntime, { createUserMessage, CallId, type Message, type StreamChunk } 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 AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -168,9 +168,15 @@ class BlockingReadFileSystem extends RecordingFileSystem { } } +async function mountWorkspaceContextPlugin(ctx: Context, config: workspaceContext.Config): Promise>> { + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + 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 mountWorkspaceContextPlugin(ctx, config) } async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise>> { @@ -178,7 +184,7 @@ 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 mountWorkspaceContextPlugin(ctx, config) } function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { @@ -986,26 +992,26 @@ 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(mountWorkspaceContextPlugin(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 mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) } finally { await ctx.fiber.dispose() } }) - it('does not declare fs as a static inject dependency', () => { - expect('inject' in workspaceContext).toBe(false) + it('requires projections without making the optional filesystem a static dependency', () => { + expect(workspaceContext.inject).toEqual(['sessionProjections']) }) 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 mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const agent = stubAgent('/virtual/repo') await composeBaselinePrefix(ctx, agent) @@ -1112,7 +1118,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const original = stubAgent(root) await composeBaselinePrefix(ctx, original) @@ -1356,7 +1362,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 mountWorkspaceContextPlugin(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) @@ -1402,7 +1408,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 mountWorkspaceContextPlugin(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) @@ -1455,7 +1461,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 mountWorkspaceContextPlugin(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) @@ -1650,7 +1656,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(1) @@ -1680,7 +1686,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) const baseline = baselineEvents(agent)[0] @@ -1695,7 +1701,7 @@ describe('workspace context request injection', () => { }) await fiber.dispose() - await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) await composeBaselinePrefix(ctx, agent) expect(baselineEvents(agent)).toHaveLength(2) @@ -1982,7 +1988,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2005,7 +2011,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2047,7 +2053,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2072,7 +2078,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) @@ -2095,7 +2101,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel prefix') const pending = agentEvents(ctx, stubAgent(root)).waterfall( @@ -2129,7 +2135,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2155,7 +2161,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2178,7 +2184,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2201,7 +2207,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2223,7 +2229,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2248,7 +2254,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2298,7 +2304,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 mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const agent = stubAgent(cwd) await composeBaselinePrefix(ctx, agent) @@ -2319,7 +2325,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 mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const agent = stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2515,8 +2521,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 ctx.plugin(SessionProjectionRegistry) + await mountWorkspaceContextPlugin(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 }) @@ -2592,7 +2597,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel dynamic reconciliation') controller.abort(reason) @@ -2855,7 +2860,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -2892,7 +2897,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) await ctx.tools.execute({ @@ -2937,7 +2942,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const firstAgent = stubAgent(root) const secondAgent = stubAgent(root) @@ -3473,7 +3478,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -3852,7 +3857,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const result = await ctx.tools.execute({ @@ -3993,7 +3998,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4062,7 +4067,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const blocked = await ctx.tools.execute({ @@ -4086,7 +4091,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 mountWorkspaceContextPlugin(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' }) @@ -4161,7 +4166,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4184,7 +4189,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const agent = stubAgent('/') const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null } @@ -4233,7 +4238,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() try { await ctx.plugin(RecordingFileSystem) - await ctx.plugin(workspaceContext, { maxBytes: 65536 }) + await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const root = resolve('/') const agent = stubAgent(root) @@ -4299,7 +4304,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 20 }) const agent = stubAgent(root) const first = await ctx.tools.execute({ @@ -4421,7 +4426,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 1 }) const agent = stubAgent(root) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, @@ -4497,7 +4502,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, @@ -4537,7 +4542,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 mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const agent = stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, diff --git a/packages/context/agent-instructions/tsconfig.json b/packages/context/agent-instructions/tsconfig.json index ecdccf49b5..b22904487a 100644 --- a/packages/context/agent-instructions/tsconfig.json +++ b/packages/context/agent-instructions/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session/session-projection" + }, { "path": "../../core/tools" }, diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index c3b84d285f..c551b83f58 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -34,10 +34,8 @@ const timeContextStateSchema = zod.object({ 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(), + /** Latest injection time in the open turn, or null before that turn receives one. */ + lastTurnInjectionTime: zod.number().nullable(), }) /** Folded time-context readings. */ @@ -151,15 +149,12 @@ export function apply(ctx: Context, config: Config): void { ctx.sessionProjections.register({ key: 'timeContext', - stateVersion: 1, + stateVersion: 2, stateSchema: timeContextStateSchema, - init: () => ({ lastMessageTime: null, lastInjectionTime: null, lastInjectionTurn: null, currentTurn: null }), + init: () => ({ lastMessageTime: null, lastInjectionTime: null, lastTurnInjectionTime: 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 === 'turn/start' || event.type === 'turn/end') { + return state.lastTurnInjectionTime === null ? state : { ...state, lastTurnInjectionTime: null } } if (event.type === 'user/message') { const injected = event.data.source.kind === 'plugin' && event.data.source.plugin === name @@ -170,7 +165,7 @@ export function apply(ctx: Context, config: Config): void { return { ...withMessage, lastInjectionTime: event.time, - lastInjectionTurn: state.currentTurn, + lastTurnInjectionTime: event.time, } } if (event.type === 'assistant/message' || event.type === 'tool/result') { @@ -196,12 +191,10 @@ export function apply(ctx: Context, config: Config): void { } /* 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 */ + /* v8 ignore next 6 -- every later step follows a recorded injection in the same turn */ const previous = step === 1 ? state.lastMessageTime ?? undefined - : state.lastInjectionTurn === turn - ? state.lastInjectionTime ?? undefined - : undefined + : state.lastTurnInjectionTime ?? 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 f1c0f70d12..886e6a020d 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -415,22 +415,28 @@ describe('configuration and lifecycle', () => { }) describe('time-context projection fold edges', () => { - it('keeps the same turn when a repeated turn/start lands', async () => { + it('clears the open-turn injection time at the next turn start', async () => { const { ctx } = await mount() const session = Session.create(SessionId('same-turn')) session.append('turn/start', { turn: 1 }) - session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'reading' }], + source: { kind: 'plugin', plugin: 'time-context' }, + }), { surfaceOp: 'append' }) + expect(typeof ctx.sessionProjections.stateOf(session, 'timeContext')?.lastTurnInjectionTime).toBe('number') + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2 }) expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ - currentTurn: 1, + lastTurnInjectionTime: null, }) }) - it('stays null when turn/end arrives without a turn/start', async () => { + it('keeps the open-turn injection time null when turn/end arrives first', 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.stateOf(session, 'timeContext')).toMatchObject({ - currentTurn: null, + lastTurnInjectionTime: null, }) }) }) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 04c1d7897a..0f0f4858c7 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -41,7 +41,6 @@ const INACTIVE_STATES: ReadonlySet = new Set([ ]) 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({ @@ -54,10 +53,9 @@ const turnBoundaryProjectionSchema: zod.ZodType = zod.ob /** Host projection of agent turn and step boundaries. */ export const turnBoundaryProjectionDefinition = { key: 'turnBoundary', - stateVersion: 1, + stateVersion: 2, stateSchema: turnBoundaryProjectionSchema, init: () => ({ - openTurn: null, openTurnStartSeq: null, lastStepStartSeq: null, lastStepBoundary: null, @@ -68,14 +66,12 @@ export const turnBoundaryProjectionDefinition = { 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': diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index cc3cf477b8..79461caad6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -37,8 +37,6 @@ export type InboxTarget = 'next-turn' | 'next-step' * corrupt state — and never treat it as an error. */ 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. */ diff --git a/packages/experimental/agent-team/src/journal.ts b/packages/experimental/agent-team/src/journal.ts index 391966bcbc..773866e814 100644 --- a/packages/experimental/agent-team/src/journal.ts +++ b/packages/experimental/agent-team/src/journal.ts @@ -3,9 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { Context } from '@deepseek-ai/cordis' import type { SessionEventMap, SessionId } from '@deepseek-ai/dsh-session' -import { emptyTeamState } from './projection.ts' import type { TeamEventType, TeamState } from './projection.ts' -import { TeamId } from './types.ts' type AppendTeamEvent = (type: T, data: SessionEventMap[T]) => void type MutableTeamEventType = 'team/member' | 'team/task' | 'team/message/queued' | 'team/message/delivered' @@ -31,9 +29,8 @@ export class TeamJournal { state(root: Agent): TeamState { const projection = this.ctx.sessionProjections.stateOf(root.session, 'team') if (projection === undefined) throw new Error('Agent Teams projection is not registered') - const selected = projection.teams.find(team => team.id === TeamId(root.id)) - if (selected?.failure !== undefined) throw new Error(selected.failure) - return selected ?? emptyTeamState(root.id) + if (projection.failure !== undefined) throw new Error(projection.failure) + return projection } /** diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index 1ab43a0b53..ad3d0c8357 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -139,7 +139,7 @@ export interface TeamState { * @param rootId - root Session identity. * @returns mutable empty Team state. */ -export function emptyTeamState(rootId: SessionId): TeamProjectionEntry { +export function emptyTeamState(rootId: SessionId): TeamProjectionState { return { id: toTeamId(rootId), members: [], @@ -150,16 +150,11 @@ export function emptyTeamState(rootId: SessionId): TeamProjectionEntry { } } -/** One checkpoint-safe Team state retained by its durable identity. */ -export interface TeamProjectionEntry extends TeamState { +/** Checkpoint-safe state for the Team owned by the projected Session. */ +export interface TeamProjectionState extends TeamState { failure?: string } -/** Plain-JSON Team states grouped by identity for fork isolation. */ -export interface TeamProjectionState { - readonly teams: TeamProjectionEntry[] -} - declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { team: TeamProjectionState @@ -174,10 +169,6 @@ const teamProjectionEntrySchema = z.object({ delivered: z.array(teamMessageIdSchema), nextTaskNumber: positiveSafeInteger, failure: z.string().optional(), -}).strict() as z.ZodType - -const teamProjectionStateSchema = z.object({ - teams: z.array(teamProjectionEntrySchema), }).strict() as z.ZodType /** Whether one event belongs to the Team domain. */ @@ -230,22 +221,17 @@ function parseCurrentTeamEvent(event: TeamSessionEvent): TeamSessionEvent { function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): void { if (!isTeamEvent(event)) return - const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data) - const current = selector.version === 1 ? parseCurrentTeamEvent(event) : undefined - let team = state.teams.find(candidate => candidate.id === selector.teamId) - if (team === undefined) { - team = emptyTeamState(SessionId(selector.teamId)) - state.teams.push(team) - } - if (team.failure !== undefined) return + if (state.failure !== undefined) return try { - if (current === undefined) { + const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data) + if (selector.teamId !== state.id) return + if (selector.version !== 1) { throw new Error(`unsupported Agent Teams event version ${String(selector.version)}`) } - applyCurrentTeamEvent(team, current) + applyCurrentTeamEvent(state, parseCurrentTeamEvent(event)) } catch (error: unknown) { /* v8 ignore next -- the owned Team transition throws Error instances. */ - team.failure = error instanceof Error ? error.message : String(error) + state.failure = error instanceof Error ? error.message : String(error) } } @@ -318,12 +304,12 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void } } -/** Host-only Team projection grouped by durable Team identity. */ +/** Host-only Team projection selected by the projected Session identity. */ export const teamProjectionDefinition = { key: 'team', - stateVersion: 1, - stateSchema: teamProjectionStateSchema, - init: (): TeamProjectionState => ({ teams: [] }), + stateVersion: 2, + stateSchema: teamProjectionEntrySchema, + init: header => emptyTeamState(header.id), apply: (state, event) => { applyProjectionEvent(state, event) return state diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 06295aadab..88e72e5506 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -16,7 +16,7 @@ import SubagentService, { seedDescriptorTurn, snapshotSubagentDescriptor } from import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import TeamService, { TeamId, TeamMessageId } from '../src/index.ts' -import { emptyTeamState, teamProjectionDefinition } from '../src/projection.ts' +import { teamProjectionDefinition } from '../src/projection.ts' import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/index.ts' import { TestSessionQuery } from './test-session-query.ts' @@ -31,11 +31,10 @@ function durable(agent: Agent): { tasks: TeamTaskSnapshot[] pendingMessages: TeamMessageSnapshot[] } { - let projected = teamProjectionDefinition.init() + let projected = teamProjectionDefinition.init(agent.session.header) for (const event of agent.session.events) projected = teamProjectionDefinition.apply(projected, event) - const selected = projected.teams.find(team => team.id === TeamId(agent.id)) - if (selected?.failure !== undefined) throw new Error(selected.failure) - const state = selected ?? emptyTeamState(agent.id) + if (projected.failure !== undefined) throw new Error(projected.failure) + const state = projected return { members: state.members, tasks: state.tasks, diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index b5d050a830..a572fc74b1 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session' -import { emptyTeamState, teamProjectionDefinition } from '../src/projection.ts' +import { teamProjectionDefinition } from '../src/projection.ts' import type { TeamProjectionState, TeamState } from '../src/projection.ts' import { TeamId, TeamMessageId, TeamTaskId } from '../src/types.ts' import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/types.ts' @@ -14,20 +14,19 @@ function event(type: T, data: SessionEventMap[T], se return { type, data, seq, time: seq } as SessionEvent } -function project(events: readonly SessionEvent[]): TeamProjectionState { - let state = teamProjectionDefinition.init() +function project(rootId: SessionId, events: readonly SessionEvent[]): TeamProjectionState { + let state = teamProjectionDefinition.init({ version: 0, id: rootId, createdAt: 0 }) for (const event of events) state = teamProjectionDefinition.apply(state, event) return state } -function teamState(rootId: SessionId, projected: TeamProjectionState): TeamState { - const selected = projected.teams.find(team => team.id === TeamId(rootId)) - if (selected?.failure !== undefined) throw new Error(selected.failure) - return selected ?? emptyTeamState(rootId) +function teamState(projected: TeamProjectionState): TeamState { + if (projected.failure !== undefined) throw new Error(projected.failure) + return projected } function projectTeam(rootId: SessionId, events: readonly SessionEvent[]): TeamState { - return teamState(rootId, project(events)) + return teamState(project(rootId, events)) } /** Queued-minus-delivered mail retained by the projection. */ @@ -91,10 +90,9 @@ describe('Agent Teams projection events', () => { event('team/task', { version: 1, teamId: TEAM, task: task({ id: TeamTaskId('task-7') }) }, 3), event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, 4), ] - const projected = project(records) - const state = teamState(ROOT, projected) + const projected = project(ROOT, records) + const state = teamState(projected) - expect(projected.teams.map(team => team.id)).toEqual([TeamId('ancestor'), TEAM]) expect(state).toMatchObject({ id: TEAM }) expect(state.members).toHaveLength(1) expect(state.tasks).toHaveLength(1) @@ -305,7 +303,7 @@ describe('Agent Teams projection events', () => { teamId: TEAM, task: task(), }, 0) - const state = project([invalid]).teams[0]! + const state = project(ROOT, [invalid]) expect(state.failure).toMatch(/unsupported Agent Teams event version 2/) expect(isEmptyState(state)).toBe(true) }) @@ -316,12 +314,12 @@ describe('Agent Teams projection events', () => { teamId: TeamId('ancestor'), task: task(), }, 0) - const projected = project([inherited]) - expect(projected.teams[0]?.failure).toMatch(/unsupported Agent Teams event version 2/) - expect(isEmptyState(teamState(ROOT, projected))).toBe(true) + const projected = project(ROOT, [inherited]) + expect(projected.failure).toBeUndefined() + expect(isEmptyState(teamState(projected))).toBe(true) }) - it('still validates complete current-version records inherited from another Team', () => { + it('ignores malformed current-version records inherited from another Team', () => { const inherited = { ...event('team/task', { version: 1, @@ -334,7 +332,6 @@ describe('Agent Teams projection events', () => { task: { ...task(), subject: 42 }, }, } as unknown as SessionEvent - expect(() => projectTeam(ROOT, [inherited])) - .toThrow(/persisted Agent Teams team\/task payload is invalid/) + expect(isEmptyState(projectTeam(ROOT, [inherited]))).toBe(true) }) }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 49f0ca3321..c6c7ebfb31 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -16,7 +16,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import TeamService, { TeamError, TeamId, TeamMessageId, TeamTaskId } from '../src/index.ts' import { TeamRuntimeLifecycle } from '../src/lifecycle.ts' -import { emptyTeamState, teamProjectionDefinition } from '../src/projection.ts' +import { teamProjectionDefinition } from '../src/projection.ts' import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/index.ts' import { TestSessionQuery } from './test-session-query.ts' @@ -34,11 +34,10 @@ function durable(agent: Agent): { tasks: TeamTaskSnapshot[] pendingMessages: TeamMessageSnapshot[] } { - let projected = teamProjectionDefinition.init() + let projected = teamProjectionDefinition.init(agent.session.header) for (const event of agent.session.events) projected = teamProjectionDefinition.apply(projected, event) - const selected = projected.teams.find(team => team.id === TeamId(agent.id)) - if (selected?.failure !== undefined) throw new Error(selected.failure) - const state = selected ?? emptyTeamState(agent.id) + if (projected.failure !== undefined) throw new Error(projected.failure) + const state = projected return { members: state.members, tasks: state.tasks, diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 7a02532e54..c95ce32943 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -14,10 +14,12 @@ 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' import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { - decodeGoalChange, + applyGoalEvent, goalChangeRef, } from './fold.ts' +import type { GoalFoldState } from './fold.ts' import { GOAL_CHANGE_VERSION, GoalError, @@ -31,6 +33,7 @@ import type { GoalBlockReason, GoalPhase, GoalProjection, + GoalProjectionState, GoalRef, GoalSnapshot, GoalView, @@ -76,47 +79,94 @@ const goalProjectionSchema: ZodType = zod.union([ zod.null(), ]) as ZodType +const goalProjectionStateSchema: ZodType = zod.object({ + current: goalProjectionSchema, + seenGoalIds: zod.array(zod.string().min(1)).refine( + ids => new Set(ids).size === ids.length, + { message: 'seen goal ids must be unique' }, + ), + failure: zod.string().min(1).nullable(), +}).strict().superRefine((state, context) => { + if (state.current === null) return + if (!state.seenGoalIds.includes(state.current.goal.id)) { + context.addIssue({ code: 'custom', message: 'current goal id must be retained among seen goal ids' }) + } + if (state.current.updatedAt < state.current.createdAt) { + context.addIssue({ code: 'custom', message: 'current goal update cannot precede its creation' }) + } + if (state.current.roundsStarted > state.current.goal.maxGoalRounds) { + context.addIssue({ code: 'custom', message: 'current goal rounds cannot exceed its configured limit' }) + } +}) as unknown as ZodType + +/** Build strict fold state from one checkpoint-safe projection state. */ +function goalFoldState(state: GoalProjectionState): GoalFoldState { + return { + goal: state.current?.goal, + roundsStarted: state.current?.roundsStarted ?? 0, + createdAt: state.current?.createdAt, + updatedAt: state.current?.updatedAt, + lastRef: undefined, + seenGoalIds: new Set(state.seenGoalIds), + } +} + +/** Convert strict fold state into checkpoint-safe projection state. */ +function goalProjectionState(state: GoalFoldState): GoalProjectionState { + let current: GoalProjection | null = null + if (state.goal !== undefined) { + const { createdAt, updatedAt } = state + if (createdAt === undefined || updatedAt === undefined) { + throw new Error('current goal fold lacks timestamps') + } + current = { + goal: state.goal, + roundsStarted: state.roundsStarted, + createdAt, + updatedAt, + } + } + return { + current, + seenGoalIds: [...state.seenGoalIds], + failure: null, + } +} + /** - * Light fold of the `goal` projection unit. Unlike the strict - * replay fold (fold.ts: transition validation, fail-loud on malformed - * changes, Set-typed state), this transition is projection-grade: the state - * is plain JSON (persisted-cache precondition), any non-goal or malformed - * event returns the same reference (the registry's Object.is gate — the - * title/todos posture), and correctness of the written change is the write - * side's job (GoalService validated it before appending; the package - * invariant rejects a violating stream fail-loud where it is installed). + * Fold durable goal events through the strict replay rules without throwing + * from the projection registry's event drive. The first invalid owned event + * is retained in `failure`; host goal access rejects that state while the + * client view remains at the last valid goal. * @param state - the projection covering all prior events. * @param event - the next committed session event. * @returns the next projection (same reference when the event is unrelated). */ -export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null { - if (event.type === 'user/message' && state !== null) { - const source = event.data.source - if (source.kind !== 'goal' - || source.goalId !== state.goal.id - || source.revision !== state.goal.revision) return state - return { ...state, roundsStarted: source.round } +export function applyGoalProjection(state: GoalProjectionState, event: SessionEvent): GoalProjectionState { + if (state.failure !== null) return state + if (event.type !== 'goal/change' + && (event.type !== 'user/message' || event.data.source.kind !== 'goal')) return state + const folded = goalFoldState(state) + try { + applyGoalEvent(folded, event) + return goalProjectionState(folded) + } catch (error: unknown) { + /* v8 ignore next -- the strict goal fold throws Error instances. */ + const message = error instanceof Error ? error.message : String(error) + return { ...state, failure: `goal replay failed at session event ${event.seq}: ${message}` } } - if (event.type === 'goal/change') { - let change: GoalChangeMeta | undefined - try { - change = decodeGoalChange(event.data) - } catch (_invalidPersistedGoalChange) { - return state - } - if (change === undefined) return state - return change.operation === 'clear' - ? null - : { - goal: change.goal, - roundsStarted: change.roundsStarted, - createdAt: change.createdAt, - updatedAt: change.updatedAt, - } - } - return state } +/** Strict host goal state with the existing cropped client value. */ +export const goalProjectionDefinition = { + key: 'goal', + stateSchema: goalProjectionStateSchema, + init: (): GoalProjectionState => ({ current: null, seenGoalIds: [], failure: null }), + apply: applyGoalProjection, + wire: { viewSchema: goalProjectionSchema, view: state => state.current }, + stateVersion: 6, +} satisfies ProjectionDefinition<'goal', GoalProjectionState> + /** Deployment defaults for goal creation. */ export interface Config { /** Total rounds used when a create request omits its own cap. */ @@ -201,14 +251,7 @@ export class GoalService extends TypertRemoteService { ctx.on('agent/session-start', ({ agent }) => { this.runtimeState(agent.session).activation = 'disarmed' }) - ctx.sessionProjections.register<'goal', GoalProjection | null>({ - key: 'goal', - stateSchema: goalProjectionSchema, - init: () => null, - apply: applyGoalProjection, - wire: { viewSchema: goalProjectionSchema, view: state => state }, - stateVersion: 5, - }) + ctx.sessionProjections.register(goalProjectionDefinition) ctx.on('session/event', (session, event) => { if (event.type !== 'goal/change') return const runtime = this.runtimeState(session) @@ -424,7 +467,11 @@ export class GoalService extends TypertRemoteService { /** Read the current durable projection maintained by the registry. */ private state(session: Session): GoalProjection | null { - return this.ctx.sessionProjections.stateOf(session, 'goal') as GoalProjection | null + const state = this.ctx.sessionProjections.stateOf(session, 'goal') + /* v8 ignore next -- GoalService registers its required projection in the constructor. */ + if (state === undefined) throw new Error('goal projection is not registered') + if (state.failure !== null) throw new Error(state.failure) + return state.current } /** Return the process-local activation state, initially disarmed. */ @@ -536,7 +583,7 @@ export class GoalService extends TypertRemoteService { runtime.pendingActivation = { seq: agent.session.seq, activation } try { const event = agent.session.append('goal/change', change) - if (runtime.pendingActivation?.seq === event.seq) runtime.activation = activation + if (runtime.pendingActivation.seq === event.seq) runtime.activation = activation } finally { runtime.pendingActivation = undefined } diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index 0ef17dd1a6..bb7d8b9e1c 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -99,9 +99,19 @@ export interface GoalProjection { readonly updatedAt: number } +/** Strict checkpoint state used to derive the current goal client value. */ +export interface GoalProjectionState { + /** Latest valid current goal, or null before creation and after clear. */ + readonly current: GoalProjection | null + /** Goal identities already created in this Session, retained to reject reuse. */ + readonly seenGoalIds: GoalId[] + /** First strict replay failure, or null while the durable stream is valid. */ + readonly failure: string | null +} + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { - goal: GoalProjection | null + goal: GoalProjectionState } interface SessionProjectionMap { /** diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index a6b8ab1725..d9ab29008c 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -3,9 +3,9 @@ * 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. + * key; unmounting drops it (HMR safety). The host state retains strict replay + * failures without throwing from the registry drive, and GoalService rejects + * access after such a failure. */ import { describe, expect, it, vi } from 'vitest' @@ -17,8 +17,8 @@ import type { UserMessage } from '@deepseek-ai/dsh-session' import SessionStore from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import GoalService, { GoalId, applyGoalProjection, foldGoal } from '@deepseek-ai/dsh-goal' -import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal' +import GoalService, { GoalId, applyGoalProjection, foldGoal, goalProjectionDefinition } from '@deepseek-ai/dsh-goal' +import type { GoalProjection, GoalProjectionState, GoalRef } from '@deepseek-ai/dsh-goal' interface Bench { ctx: Context @@ -139,23 +139,34 @@ describe('goal projection unit', () => { expect(foldGoal(bench.session.events).goal).toBeUndefined() }) - it('ignores non-goal and malformed goal-shaped events fail-soft (same reference)', () => { - // The package invariant rejects a violating stream loudly wherever it is - // installed — the unit itself must never throw on the projection drive - // (a throwing apply would tear down every registered unit's drive), so - // its transition is exercised directly as the pure function it is. + it('retains strict replay failures without throwing from the projection drive', () => { const plainUser = createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }) const user = { type: 'user/message', seq: 0, time: 1, data: plainUser } as never - const state: GoalProjection = { + const current: GoalProjection = { goal: { id: GoalId('g1'), revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 4 }, roundsStarted: 0, createdAt: 1, updatedAt: 1, } - const empty = null + const state: GoalProjectionState = { + current, + seenGoalIds: [current.goal.id], + failure: null, + } + expect(goalProjectionDefinition.stateSchema.parse(state)).toEqual(state) + expect(goalProjectionDefinition.stateSchema.safeParse({ ...state, seenGoalIds: [] }).success).toBe(false) + expect(goalProjectionDefinition.stateSchema.safeParse({ + ...state, + current: { ...current, createdAt: 2, updatedAt: 1 }, + }).success).toBe(false) + expect(goalProjectionDefinition.stateSchema.safeParse({ + ...state, + current: { ...current, roundsStarted: current.goal.maxGoalRounds + 1 }, + }).success).toBe(false) + const empty = goalProjectionDefinition.init() expect(applyGoalProjection(empty, user)).toBe(empty) const admittedRound = { type: 'user/message', seq: 1, time: 2, @@ -164,21 +175,22 @@ describe('goal projection unit', () => { source: { kind: 'goal', goalId: 'g1', revision: 1, round: 1 } as never, }), } as never - expect(applyGoalProjection(state, admittedRound)).toEqual({ ...state, roundsStarted: 1 }) + expect(applyGoalProjection(state, admittedRound)).toEqual({ + ...state, + current: { ...current, roundsStarted: 1 }, + }) const queuedUser = { type: 'agent/inbox/spliced', seq: 1, time: 2, data: { target: 'next-step', start: 0, inserted: [plainUser] }, } as never - const current = state - expect(applyGoalProjection(current, queuedUser)).toBe(current) + expect(applyGoalProjection(state, queuedUser)).toBe(state) const malformed = { type: 'goal/change', seq: 1, time: 2, data: { kind: 'goal/change', version: 1, operation: 'create' }, } as never - // Same-reference return: the registry's Object.is gate sees no change. - expect(applyGoalProjection(current, malformed)).toBe(current) - expect(applyGoalProjection(empty, malformed)).toBe(empty) + expect(applyGoalProjection(state, malformed).failure).toMatch(/goal snapshot change must have exactly/) + expect(applyGoalProjection(empty, malformed).failure).toMatch(/goal snapshot change must have exactly/) const queuedRound = { type: 'agent/inbox/spliced', seq: 3, time: 4, @@ -187,16 +199,27 @@ describe('goal projection unit', () => { source: { kind: 'goal', goalId: 'g1', revision: 1, round: 1 } as never, })] }, } as never - expect(applyGoalProjection(current, queuedRound)).toBe(current) + expect(applyGoalProjection(state, queuedRound)).toBe(state) // A non-message event (the registry drives EVERY committed event through // apply): early same-reference return. const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1 } } as never - expect(applyGoalProjection(current, turnStart)).toBe(current) + expect(applyGoalProjection(state, turnStart)).toBe(state) - // A goal/change event whose payload carries a foreign kind is ignored. + // A declared goal/change record with a foreign payload kind is an owned-stream failure. const foreignKind = { type: 'goal/change', seq: 4, time: 5, data: { kind: 'not-a-goal-change' } } as never - expect(applyGoalProjection(current, foreignKind)).toBe(current) + expect(applyGoalProjection(state, foreignKind).failure).toMatch(/invalid kind/) + }) + + it('fails host goal access when the projection retained a replay failure', async () => { + const bench = await harness(true) + const failure = 'goal replay failed at session event 0: invalid restored goal stream' + const state = bench.ctx.sessionProjections.stateOf(bench.session, 'goal') + expect(state).toBeDefined() + Object.assign(state!, { failure }) + + expect(() => bench.ctx.goals.get(bench.agent)).toThrow(failure) + expect(bench.tailValues().goal).toBeNull() }) it('has no goal key when the goal service is not composed', async () => { diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 0684ea1b7d..64a820a43b 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -32,7 +32,7 @@ import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' -import type {} from '@deepseek-ai/dsh-commands' +import type { CommandId } from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { PlanProjection, PlanUnitState } from './types.ts' @@ -116,7 +116,7 @@ export function resolveConfig(config: PlanModeConfig): PlanModeConfig { const planUnitStateSchema = zod.object({ active: zod.boolean(), wanted: zod.boolean().nullable(), - running: zod.object({ commandId: zod.string(), wanted: zod.boolean() }).nullable(), + running: zod.object({ commandId: zod.string() as unknown as ZodType, wanted: zod.boolean() }).nullable(), activeAtLastHeader: zod.boolean().nullable(), }) @@ -367,7 +367,7 @@ export class PlanModeController extends Service { } private hasOpenTurn(session: Session): boolean { - return (this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.openTurn ?? null) !== null + return (this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.openTurnStartSeq ?? null) !== null } private loggedActiveAtLastHeader(session: Session): boolean | undefined { diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts index ffb19aee1e..3325c6b0c4 100644 --- a/packages/plan/plan-mode/src/types.ts +++ b/packages/plan/plan-mode/src/types.ts @@ -8,6 +8,8 @@ * @module @deepseek-ai/dsh-plan-mode/types */ +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' + /** * The plan projection's wire value. `active` is the logged state in force * (the last `plan/mode`, inactive before the first); `pending` is true while @@ -28,7 +30,7 @@ export interface PlanUnitState { /** 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 + running: { commandId: CommandId; wanted: boolean } | null /** Active state recorded by the latest `request/header`, or null. */ activeAtLastHeader: boolean | null } diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 6be67f11b4..3ed3f34d5f 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -153,7 +153,7 @@ declare module '@deepseek-ai/cordis' { * and a preset deleted underneath a picker disappears from the next read. */ export class AgentPresets extends TypertRemoteService { - static inject = ['loader'] + static inject = ['loader', 'sessionProjections'] /** Runtime schema for the preset roster. */ static Config = z.object({ @@ -229,9 +229,7 @@ export class AgentPresets extends TypertRemoteService { }, 'agentPresets.settings()') }) - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(agentPresetProjectionDefinition) - }) + ctx.sessionProjections.register(agentPresetProjectionDefinition) // Advisory, not fatal: a synchronous `agent/created` listener that throws // VETOES publication, and this service must not, because composing an agent @@ -692,7 +690,11 @@ export class AgentPresets extends TypertRemoteService { // conversation may have started, since this call was queued. A turn is one // model-loop execution; standalone plugin events never open one, so a // session that has only run commands is still blank. - if (agent.session.events.some(event => event.type === 'turn/start')) { + const boundary = this.selfCtx.sessionProjections.stateOf(agent.session, 'turnBoundary') + if (boundary === undefined) { + throw new Error('agent-presets: select requires the turnBoundary session projection') + } + if (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0) { throw new PresetLockedError(agent.id, agentPreset) } const preset = await this.recompose(agent.ctx, agentPreset) diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index 2166229cf6..b966c7b12a 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -14,9 +14,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url' 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 { beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { - COMPOSITION_FILE, copyComposition, METADATA_FILE, + COMPOSITION_FILE, copyComposition, METADATA_FILE, type Config, } from '@deepseek-ai/dsh-agent-presets' const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') @@ -25,6 +26,12 @@ const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\ let ctx: Context let userRoot: string +/** Mount the required projection seam before the roster service. */ +async function mountAgentPresets(context: Context, config: Config): Promise { + await context.plugin(SessionProjectionRegistry) + await context.plugin(AgentPresets, config) +} + /** Hand-craft a preset directory (tests cannot author text through the service). */ async function seedPreset( root: string, id: string, options: { composition?: string; metadata?: string; extras?: Record } = {}, @@ -46,7 +53,7 @@ beforeEach(async () => { ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include - await ctx.plugin(AgentPresets, { + await mountAgentPresets(ctx, { default: 'standard', roots: [ { path: join(FIXTURES, 'system'), trust: 'system' as const }, @@ -199,7 +206,7 @@ describe('a deployment with more than one user root', () => { layered.baseUrl = pathToFileURL(FIXTURES).href + '/' await layered.plugin(Loader) layered.loader.builtins.include = Include - await layered.plugin(AgentPresets, { + await mountAgentPresets(layered, { default: 'standard', roots: [ { path: userRoot, trust: 'user' as const }, @@ -224,7 +231,7 @@ describe('a deployment with no writable root', () => { readOnly.baseUrl = pathToFileURL(FIXTURES).href + '/' await readOnly.plugin(Loader) readOnly.loader.builtins.include = Include - await readOnly.plugin(AgentPresets, { + await mountAgentPresets(readOnly, { default: 'standard', roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], includeShippedRoot: false, @@ -244,7 +251,7 @@ describe('a user root that does not exist yet', () => { fresh.baseUrl = pathToFileURL(FIXTURES).href + '/' await fresh.plugin(Loader) fresh.loader.builtins.include = Include - await fresh.plugin(AgentPresets, { + await mountAgentPresets(fresh, { default: 'standard', roots: [ { path: join(FIXTURES, 'system'), trust: 'system' as const }, diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 100c724ec9..2aea1b72a6 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -381,6 +381,7 @@ describe('a roster with nothing in it', () => { it('says so instead of naming an empty list of candidates', async () => { const bare = new Context() await bare.plugin(Loader) + await bare.plugin(SessionProjectionRegistry) await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeShippedRoot: false, includeUserRoot: false }) await expect(bare.agentPresets.resolve()) diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index 3a8380cb02..406ef3f528 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -13,6 +13,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, { 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 AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -72,6 +73,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: [] }) await ctx.plugin(AgentPresets, roster) return ctx diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index 703c1857f2..9dc9d78d3c 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -48,7 +48,6 @@ 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/preset/agent-presets/tests/shipped-root.spec.ts b/packages/preset/agent-presets/tests/shipped-root.spec.ts index b7981eb2d6..edb53bd5e2 100644 --- a/packages/preset/agent-presets/tests/shipped-root.spec.ts +++ b/packages/preset/agent-presets/tests/shipped-root.spec.ts @@ -16,6 +16,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include, { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as yaml from 'js-yaml' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { SHIPPED_PRESET_ROOT, type Config } from '@deepseek-ai/dsh-agent-presets' @@ -41,6 +42,7 @@ async function roster(config: Partial = {}): Promise { ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentPresets, { default: 'standard', roots: [], diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts index c749db8a75..6da05b2ca7 100644 --- a/packages/preset/agent-presets/tests/user-root.spec.ts +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -18,6 +18,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' 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 { afterEach, beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { COMPOSITION_FILE, type Config } from '@deepseek-ai/dsh-agent-presets' @@ -47,6 +48,7 @@ async function roster(config: Partial = {}): Promise { ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentPresets, { default: 'standard', roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }], diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index f7d6a553cb..a72f8baa6b 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -210,7 +210,30 @@ export function titleSnapshotFromState(state: TitleUnitState): SessionTitleSnaps }) } -const EMPTY_TITLE_INPUT: TitleInputState = { first: null, last: null, count: 0 } +const EMPTY_TITLE_INPUT: TitleInputState = { first: null, count: 0, lastSeq: null } + +const sessionTitleUserMessageSchema: ZodType = zod.object({ + seq: zod.number().int().nonnegative(), + text: zod.string(), +}).strict() + +const titleInputStateSchema: ZodType = zod.object({ + first: sessionTitleUserMessageSchema.nullable(), + count: zod.number().int().nonnegative(), + lastSeq: zod.number().int().nonnegative().nullable(), +}).strict().superRefine((state, context) => { + const empty = state.first === null && state.lastSeq === null && state.count === 0 + const populated = state.first !== null + && state.lastSeq !== null + && state.count > 0 + && state.first.seq <= state.lastSeq + if (!empty && !populated) { + context.addIssue({ + code: 'custom', + message: 'title input state must pair its count with first and last message seqs', + }) + } +}) /** * Collect eligible human text messages from a session log, in seq order. @@ -333,16 +356,16 @@ export class SessionTitleService extends Service { ctx.sessionProjections.register<'titleInput', TitleInputState>({ key: 'titleInput', - stateVersion: 2, - stateSchema: zod.custom(), + stateVersion: 3, + stateSchema: titleInputStateSchema, init: () => EMPTY_TITLE_INPUT, apply: (state, event) => { const message = sessionTitleUserMessageOf(event) if (message === undefined) return state return { first: state.first ?? message, - last: message, count: state.count + 1, + lastSeq: message.seq, } }, }) @@ -429,8 +452,7 @@ export class SessionTitleService extends Service { } const registration = this.registration const input = this.titleInputOf(session) - const latest = input.last - if (registration === undefined || registration.closing || latest === null) { + if (registration === undefined || registration.closing || input.lastSeq === 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. @@ -450,7 +472,7 @@ export class SessionTitleService extends Service { const work = this.activate({ registration, revision, - throughSeq: latest.seq, + throughSeq: input.lastSeq, }, state, signal) const config = session.requestHeader()?.config const route = config === undefined ? undefined : { provider: config.provider, model: config.model } diff --git a/packages/session/session-title/src/types.ts b/packages/session/session-title/src/types.ts index 6f4797d262..e6c21947d5 100644 --- a/packages/session/session-title/src/types.ts +++ b/packages/session/session-title/src/types.ts @@ -71,8 +71,8 @@ export interface TitleInputState { readonly first: SessionTitleUserMessage | null /** Total eligible messages folded so far. */ readonly count: number - /** Newest eligible message, or null before any. */ - readonly last: SessionTitleUserMessage | null + /** Seq of the newest eligible message, or null before any. */ + readonly lastSeq: number | 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 74aec3bb70..2847ca6e88 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -76,7 +76,21 @@ describe('title projection unit', () => { const state = ctx.sessionProjections.stateOf(session, 'titleInput') expect(state?.count).toBe(5_000) expect(state?.first?.text).toBe('message 0') - expect(state?.last?.text).toBe('message 4999') + expect(state?.lastSeq).toBe(session.seq - 1) expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeDefined() }) + + it('rejects a version-matching checkpoint with inconsistent title input counters', async () => { + const { ctx, session } = await harness(true) + const checkpoint = ctx.sessionProjections.checkpoint(session) + const row = checkpoint.titleInput + expect(row).toBeDefined() + const malformed = { + ...checkpoint, + titleInput: { ...row!, val: { first: null, count: 1, lastSeq: null } }, + } + + expect(() => ctx.sessionProjections.restore(malformed, [], 0, session.header)) + .toThrow(/title input state must pair its count with first and last message seqs/) + }) }) diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 302cb98251..b5ed64a696 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -41,6 +41,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-scope": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", @@ -50,7 +51,8 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f4737cbb3b..3825048229 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -33,13 +33,10 @@ import { import type { DelegationModelRequest } from './model-selection.ts' import { registerListSubagentModels } from './list-models.ts' import type {} from './model-selection-settings.ts' -import { - hasSubagentModelSelection, - recordSubagentModelSelection, -} from './model-selection-state.ts' +import { subagentModelSelectionProjectionDefinition } from './model-selection-state.ts' export const name = 'tool-subagent' -export const inject = ['tools', 'subagents', 'systemPrompt'] +export const inject = ['tools', 'subagents', 'systemPrompt', 'sessionProjections'] /** Prompt order after bounded delegation policy and before child reporting. */ const SUBAGENT_SECTION_ORDER = FIRST_PARTY_SECTION_ORDER.TOOL_SUBAGENT @@ -324,6 +321,7 @@ export function apply(ctx: Context, config: Config): void { const toolName = config.toolName ?? 'subagent' const modelSelectionCapable = config.enableModelSelection === true || config.modelSelectionSettings === true + ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => { if (typeof config.maxDepth === 'number' && !subagentProvider.capabilities.depthLimit) { @@ -616,19 +614,21 @@ export function apply(ctx: Context, config: Config): void { } const selectForAgent = (agent: NonNullable): boolean => { - let enabled = hasSubagentModelSelection(agent.session) + const recorded = ctx.sessionProjections.stateOf(agent.session, 'subagentModelSelectionEnabled') === true + let enabled = recorded if (!enabled) { const parentId = agent.session.header.origin === 'subagent' ? agent.session.header.parentSession : undefined if (parentId !== undefined) { const parent = ctx.get('agents')?.get(parentId) - enabled = parent !== undefined && hasSubagentModelSelection(parent.session) + enabled = parent !== undefined + && ctx.sessionProjections.stateOf(parent.session, 'subagentModelSelectionEnabled') === true } else if (agent.session.firstLiveSeq === 0) { enabled = settings.currentEnabled() } } - if (enabled) recordSubagentModelSelection(agent.session) + if (enabled && !recorded) agent.session.append('subagent/model-selection-enabled', {}) return enabled } diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 84bd209caa..fc7dd4705a 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -6,7 +6,7 @@ /* jscpd:ignore-start */ import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import { hasSubagentModelSelection } from './model-selection-state.ts' +import type {} from './model-selection-state.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' @@ -18,7 +18,7 @@ export const inject = ['invariants'] /** Assert that a durable opt-in is represented by both model-facing definitions. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { ctx.on('agent/pre-step', async ({ agent }, next) => { - if (hasSubagentModelSelection(agent.session)) { + if (ctx.sessionProjections.stateOf(agent.session, 'subagentModelSelectionEnabled') === true) { const schemas = ctx.tools.schemas(agent) const selectable = schemas.some((schema) => { const properties = (schema.parameters as { properties?: Record }).properties @@ -32,7 +32,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant } return next() }, { global: true }) -}, { inject: ['tools'] }) +}, { inject: ['tools', 'sessionProjections'] }) /** * Register this package's invariant companion. diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index 35345ac115..d8445c5b67 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -1,6 +1,7 @@ /** Durable per-session state for the user-controlled model-selection opt-in. */ -import type { Session } from '@deepseek-ai/dsh-session' +import { z as zod } from 'zod' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { @@ -14,20 +15,18 @@ declare module '@deepseek-ai/dsh-session/types' { } } -/** - * Whether a session log records the enabled model-selection definition. - * @param session - session whose durable decision is read. - * @returns whether model-selectable delegation is enabled for the session. - */ -export function hasSubagentModelSelection(session: Session): boolean { - return session.events.some(event => event.type === 'subagent/model-selection-enabled') +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Whether the session's delegation tool exposes child LLM route selection. */ + subagentModelSelectionEnabled: boolean + } } -/** - * Append the enabled decision once, before its definition can reach a model request. - * @param session - session receiving the enabled decision. - */ -export function recordSubagentModelSelection(session: Session): void { - if (hasSubagentModelSelection(session)) return - session.append('subagent/model-selection-enabled', {}) -} +/** Host-only projection of the durable model-selection decision. */ +export const subagentModelSelectionProjectionDefinition = { + key: 'subagentModelSelectionEnabled', + stateVersion: 1, + stateSchema: zod.boolean(), + init: () => false, + apply: (enabled, event) => enabled || event.type === 'subagent/model-selection-enabled', +} satisfies ProjectionDefinition<'subagentModelSelectionEnabled', boolean> diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index 36ac602c89..a4f3af88c7 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -5,6 +5,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' @@ -24,6 +25,7 @@ export async function setup(toolConfig: tool.Config, mockConfig: Partial { it('is omitted unless its delegation-tool instance owns discovery', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) @@ -90,6 +94,7 @@ describe('list_subagent_models', () => { it('stays registered without the optional LLM service and rejects discovery calls', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -110,6 +115,8 @@ describe('list_subagent_models', () => { it('lists registered providers and follows live registration changes', async () => { const { ctx, fiber } = await setupListTool() + const session = Session.create(SessionId('projection-lifecycle')) + expect(ctx.sessionProjections.stateOf(session, 'subagentModelSelectionEnabled')).toBe(false) const empty = await call(ctx, {}) expect(empty.isError).toBe(false) expect(text(empty)).toBe('(no LLM providers)') @@ -125,6 +132,7 @@ describe('list_subagent_models', () => { await fiber.dispose() expect(ctx.tools.get('list_subagent_models')).toBeUndefined() + expect(ctx.sessionProjections.stateOf(session, 'subagentModelSelectionEnabled')).toBeUndefined() }) it('lists one provider\'s advertised models without treating the catalog as a whitelist', async () => { diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 4f95db088f..1cb7c0bb00 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -10,6 +10,7 @@ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' import InvariantRegistry from '@deepseek-ai/dsh-invariants' 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 SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as tool from '../src/index.ts' @@ -17,7 +18,7 @@ import * as ToolInvariant from '../src/invariant.ts' import SubagentModelSelectionConfig, { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, } from '../src/model-selection-settings.ts' -import { hasSubagentModelSelection } from '../src/model-selection-state.ts' +import type {} from '../src/model-selection-state.ts' /** Writable in-memory settings provider for the package integration. */ class MemorySettings extends SettingsProvider { @@ -53,6 +54,7 @@ async function boot(): Promise { await ctx.plugin(MemorySettings) await ctx.plugin(SubagentModelSelectionConfig) await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) @@ -102,11 +104,11 @@ describe('SubagentModelSelectionConfig', () => { const ctx = await boot() const disabled = await createAgent(ctx, 'disabled') expect(selectable(ctx, disabled)).toBe(false) - expect(hasSubagentModelSelection(disabled.session)).toBe(false) + expect(ctx.sessionProjections.stateOf(disabled.session, 'subagentModelSelectionEnabled')).toBe(false) await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) const enabled = await createAgent(ctx, 'enabled') - expect(hasSubagentModelSelection(enabled.session)).toBe(true) + expect(ctx.sessionProjections.stateOf(enabled.session, 'subagentModelSelectionEnabled')).toBe(true) expect(selectable(ctx, enabled)).toBe(true) expect(selectable(ctx, disabled)).toBe(false) @@ -165,7 +167,7 @@ describe('SubagentModelSelectionConfig', () => { meta: { parentSession: parent.id, origin: 'subagent' }, }) expect(selectable(ctx, child)).toBe(true) - expect(hasSubagentModelSelection(child.session)).toBe(true) + expect(ctx.sessionProjections.stateOf(child.session, 'subagentModelSelectionEnabled')).toBe(true) const enabledSeed = Session.create(SessionId('enabled-seed')) enabledSeed.append('subagent/model-selection-enabled', {}) @@ -176,13 +178,14 @@ describe('SubagentModelSelectionConfig', () => { await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events }) expect(selectable(ctx, resumedDisabled)).toBe(false) - expect(hasSubagentModelSelection(resumedDisabled.session)).toBe(false) + expect(ctx.sessionProjections.stateOf(resumedDisabled.session, 'subagentModelSelectionEnabled')).toBe(false) await ctx.fiber.dispose() }) it('rejects ambiguous static and settings-controlled configuration', async () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) expect(() => { tool.apply(ctx, { @@ -197,6 +200,7 @@ describe('SubagentModelSelectionConfig', () => { it('requires both the Host setting owner and a composition scope', async () => { const withoutSettings = new Context() await mountAgentLoopTestDependencies(withoutSettings) + await withoutSettings.plugin(SessionProjectionRegistry) await withoutSettings.plugin(SubagentRuntime) expect(() => { tool.apply(withoutSettings, { diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 008ba66da4..d8e3d48924 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -7,6 +7,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' @@ -296,6 +297,7 @@ describe('dsh-tool-subagent model selection', () => { it('rejects selected routes or configured efforts when the LLM service is absent', async () => { const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -327,6 +329,7 @@ describe('dsh-tool-subagent model selection', () => { it('keeps pure inherited routing usable without an LLM service lookup', async () => { let starts = 0 const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index ae9220e567..d16598d933 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -12,6 +12,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' 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 SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -23,6 +24,13 @@ import * as tool from '../src/index.ts' import { Session, SessionId } from '@deepseek-ai/dsh-session' import { callSubagent, fakeAgent, setup, testToolSignal, text } from './harness.ts' +/** Create a package-test context with the tool's required projection seam. */ +async function projectedContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionProjectionRegistry) + return ctx +} + /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real * `ToolRuntime` + `SubagentRuntime`, with a package-local scripted child @@ -177,7 +185,7 @@ describe('dsh-tool-subagent', () => { // The defining multi-provider use case: two loads, two distinct tool names, // each bound to a different provider — the tool registry rejects duplicate // names, so a configurable name is what makes this work. - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -198,7 +206,7 @@ describe('dsh-tool-subagent', () => { it('treats an unknown (plugin-added) stop reason as an isError result', async () => { // SubagentStopReason is merge-extensible; the tool's stopReasonError default // arm must treat an unrecognized terminal reason as a failure, not success. - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -222,7 +230,7 @@ describe('dsh-tool-subagent', () => { it('merges model overrides over provider-owned route defaults before preflight', async () => { let seen: { agentOptions?: { provider?: string; model?: string; reasoningEffort?: string; maxTokens?: number } } | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) @@ -270,7 +278,7 @@ describe('dsh-tool-subagent', () => { it('does not inherit parent effort for a provider-owned route default', async () => { let seen: SubagentStartRequest | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) @@ -322,7 +330,7 @@ describe('dsh-tool-subagent', () => { // no-agentOptions branch are only reachable via a direct apply() that // bypasses schemastery — the same pattern acp-agent uses for its defaults. let seen: { agentOptions?: unknown } | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -357,7 +365,7 @@ describe('dsh-tool-subagent', () => { }) it('registers when the provider appears LATER — no load-order requirement (Loader starts siblings concurrently)', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -374,7 +382,7 @@ describe('dsh-tool-subagent', () => { }) it('keeps continuable guidance empty while its provider is absent', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -390,7 +398,7 @@ describe('dsh-tool-subagent', () => { }) it('mirrors the provider lifecycle: gone on backend dispose, re-derived wording on re-registration', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -409,7 +417,7 @@ describe('dsh-tool-subagent', () => { }) it('the tool PLUGIN fiber owns its lifecycle listeners: disposal unmounts, and a disposed fiber never zombie-mounts', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -445,7 +453,7 @@ describe('dsh-tool-subagent', () => { }) it('ignores lifecycle events for OTHER providers', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -485,7 +493,7 @@ describe('dsh-tool-subagent', () => { // Spy on the provider's run.dispose via a wrapping provider registered // directly on the service, then point the tool at it. const disposed = vi.fn() - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -508,7 +516,7 @@ describe('dsh-tool-subagent', () => { it('disposes the run on the error path too', async () => { const disposed = vi.fn() - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -532,7 +540,7 @@ describe('dsh-tool-subagent', () => { it('preserves independent foreground result and disposal failures', async () => { const disposed = vi.fn() - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -560,7 +568,7 @@ describe('dsh-tool-subagent', () => { }) it('reports a foreground disposal failure after a completed result', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -587,7 +595,7 @@ describe('dsh-tool-subagent', () => { it('passes the tool abort signal as the provider cancellation channel', async () => { const cancelled = vi.fn() - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -626,7 +634,7 @@ describe('dsh-tool-subagent', () => { it('skips provider startup for an already-aborted signal', async () => { const sawAborted = vi.fn() - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -653,10 +661,10 @@ describe('dsh-tool-subagent', () => { }) it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) - // No SubagentRuntime mounted. The tool injects its three required services so its + // No SubagentRuntime mounted. The tool injects its required services so its // apply never runs; the tool is absent rather than half-registered. let booted = true try { @@ -677,20 +685,20 @@ describe('dsh-tool-subagent', () => { // load with "cannot get property … without inject". Guard the shape directly. expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent') - expect(tool.inject).toEqual(['tools', 'subagents', 'systemPrompt']) + expect(tool.inject).toEqual(['tools', 'subagents', 'systemPrompt', '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-subagent') - expect(unwrapped.inject).toEqual(['tools', 'subagents', 'systemPrompt']) + expect(unwrapped.inject).toEqual(['tools', 'subagents', 'systemPrompt', 'sessionProjections']) expect(typeof unwrapped.apply).toBe('function') expect(unwrapped.Config).toBeDefined() }) it('passes persona/toolFilter/maxDepth config through to the start request', async () => { let seen: { persona?: string; toolFilter?: unknown; maxDepth?: number } | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -735,8 +743,8 @@ describe('dsh-tool-subagent', () => { .rejects.toThrow() }) - it('validates maxDepth when apply() is invoked directly without Schemastery', () => { - const ctx = new Context() + it('validates maxDepth when apply() is invoked directly without Schemastery', async () => { + const ctx = await projectedContext() expect(() => { tool.apply(ctx, { provider: 'unused', @@ -747,7 +755,7 @@ describe('dsh-tool-subagent', () => { it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => { let seen: { toolFilter?: { readonly allow?: readonly string[]; readonly deny?: readonly string[] } } | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -777,7 +785,7 @@ describe('dsh-tool-subagent', () => { // which reads as present and puts a dishonest `agentOptions: {}` on every // start request. let seen: { agentOptions?: unknown } | undefined - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -802,7 +810,7 @@ describe('dsh-tool-subagent', () => { }) it('an explicit empty toolFilter fails at plugin load, not at first delegation', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -993,7 +1001,7 @@ describe('dsh-tool-subagent background mode', () => { }) it('rejects startup when the provider changes during asynchronous route preflight', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) @@ -1208,7 +1216,7 @@ describe('dsh-tool-subagent continuable background mode', () => { /** Boot the real continuable stack without any model-facing follow-up adapter. */ async function continuableSetup() { - const ctx = new Context() + const ctx = await projectedContext() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) roots.push(root) @@ -1415,7 +1423,7 @@ describe('depth budget configuration', () => { /** Mount the tool over a request-capturing provider with full capabilities. */ async function captureSetup(config: Omit = {}) { const requests: SubagentStartRequest[] = [] - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -1453,7 +1461,7 @@ describe('depth budget configuration', () => { }) it('rejects a numeric maxDepth on a provider without the depthLimit capability at mount', async () => { - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) @@ -1469,7 +1477,7 @@ describe('depth budget configuration', () => { it("'provider-managed' omits the cap so a capability-less provider mounts and starts", async () => { const requests: SubagentStartRequest[] = [] - const ctx = new Context() + const ctx = await projectedContext() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json index 57ee1dc9d6..bb755f39a0 100644 --- a/packages/subagent/tool-subagent/tsconfig.json +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session/session-projection" + }, { "path": "../../core/scope" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96fb5a7d31..9b85e1fbb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8829,6 +8829,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:^ From e6bf040dc32c653538aead3194b38a145724eb61 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 13:51:24 +0800 Subject: [PATCH 27/72] fix(session-query): use renamed tool call id --- .../tool-session-query/tests/tool-session-query.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0f2b61675d..9db2a45da7 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 @@ -1655,7 +1655,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const result = await ctx.tools.execute({ name: 'session_event_search', arguments: { query: 'q' }, - callId: CallId('call-no-boundary'), + callId: ToolCallId('call-no-boundary'), signal: new AbortController().signal, agent: fakeAgent(session), }) From ef4dde9fe28874f19a4ada450b42c16bdeea2033 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 26 Aug 2026 13:51:32 +0800 Subject: [PATCH 28/72] docs: reconcile projection catalogs after master merge --- ...ession-projection-mandatory-seam.i18n.yaml | 2 +- ...19-session-projection-mandatory-seam.zh.md | 2 +- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../feature/2026-07-06-sandbox.zh.md | 41 +- ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...ble-subagent-catalog-and-list-agents.zh.md | 14 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.zh.md | 64 ++-- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.zh.md | 4 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.zh.md | 360 +++++++++--------- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 17 +- docs/subsystems/core.zh.md | 17 +- docs/subsystems/goal.i18n.yaml | 4 +- docs/subsystems/goal.md | 6 +- docs/subsystems/goal.zh.md | 16 +- docs/subsystems/permission-presets.i18n.yaml | 4 +- docs/subsystems/permission-presets.md | 4 +- docs/subsystems/permission-presets.zh.md | 10 +- docs/subsystems/plan.i18n.yaml | 4 +- docs/subsystems/plan.md | 4 +- docs/subsystems/plan.zh.md | 18 +- docs/subsystems/sandbox.i18n.yaml | 4 +- docs/subsystems/sandbox.md | 8 +- docs/subsystems/sandbox.zh.md | 14 +- docs/subsystems/session-title.i18n.yaml | 4 +- docs/subsystems/session-title.md | 4 +- docs/subsystems/session-title.zh.md | 8 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 78 ++-- docs/subsystems/subagent.zh.md | 2 +- docs/subsystems/token-meter.i18n.yaml | 4 +- docs/subsystems/token-meter.md | 39 +- docs/subsystems/token-meter.zh.md | 41 +- .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 2 +- packages/experimental/agent-team/README.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 14 +- 40 files changed, 466 insertions(+), 381 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml index 31a3631ce7..351fafb9d6 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml @@ -3,4 +3,4 @@ # 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-mandatory-seam.md 2026-08-19-session-projection-mandatory-seam.md: eb76ac90fa760108cbeff1033c8ec2db71c7fa11 -2026-08-19-session-projection-mandatory-seam.zh.md: d0fcb2d903b08b71d8f4c9af62d3739c9ec6970b +2026-08-19-session-projection-mandatory-seam.zh.md: 23a3cd0de99f91f09dcc22586f49de3cabcda3da diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md index d0fcb2d903..23a3cd0de9 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -本决策建立在[会话投影的 host 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.md)所定义的拆分之上。 +本决策建立在[会话投影的 host 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.zh.md)所定义的拆分之上。 每个贡献或读取投影单元的插件都把 `sessionProjections` 作为必需注入。正式组合在这些插件之前挂载注册表。`ApiProxyService` 遵循同一规则;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 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 404f863e75..e89b17232f 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: 491f103ebb59b2103d0140b3e1ad935d79018361 -2026-07-06-sandbox.zh.md: 0df77f595b82e0bb69f29aac21d4c46c21f7a47a +2026-07-06-sandbox.md: c4153c6ef47b6d11290094adf06b8107418be889 +2026-07-06-sandbox.zh.md: 9191b1b8d2d4e65acab8b935d0da170066876aea 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 0df77f595b..9191b1b8d2 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -14,11 +14,11 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 ## 决策 -一个 seam、一条按平台的本地后端链、一个消费方,加上两个上层控制项:按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来;不触及 `agent-loop`。跨工具族 fs 强制与按会话工作区根目录已经作为后续设计落到同一策略载体上;剩余阶段——`subagent-acp` 消费方、更多环境与 Windows 链——仍列在 § 延迟阶段。 +一个 seam、一条按平台的本地后端链、一个消费方,加上两个上层控制项:按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来;不触及 `agent-loop`。跨工具族 fs 强制、按会话工作区根目录与 Windows 链已经作为后续设计落到同一策略载体上;`subagent-acp` 消费方与更多环境仍列在 § 延迟阶段。 ### 部署方式 -四条 `cordis.yml` 条目即可将一个无约束的编码 agent 转变为沙箱产品路径;[`examples/acp-agent`](../../../../examples/acp-agent/README.md) 默认使用此组合: +四条 `cordis.yml` 条目即可将一个无约束的编码 agent 转变为沙箱产品路径;[基础 profile 组合包](../../../../packages/bundle/base/cordis.patch.yml)默认使用此组合: ```yaml - id: sandbox @@ -40,7 +40,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是 配置错误会显式导致失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。如果所选 runner 以可归因的 `ENOENT` 或 `EACCES` 拒绝,消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误;其他 spawn 错误仍保留本地命令启动语义,同时也不会运行任何内容。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner 钩子。 -被拒绝的文件操作返回 `[sandbox: file access denied under mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to ""`,且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission-presets` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP(Agent Client Protocol)自动化组合](../../../../examples/acp-agent/README.md)不挂载该 UI 服务,而是显式选定其部署模式。 +被拒绝的文件操作返回 `[sandbox: file access denied under mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to ""`,且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission-presets` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP(Agent Client Protocol)应用组合包](../../../../packages/bundle/acp-app/README.zh.md)不挂载该 UI 服务,而是显式选定其部署模式。 ### 设计细节 @@ -64,7 +64,7 @@ OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还 launcher 是一个约 300 行的 C 程序(纯 C11,直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 约定):`--ro ` / `--rw ` 授权,`--`,被包装的 argv;它为自身安装规则集并执行 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs`);`--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出;所有 launcher 失败都会以 125 退出且不运行子进程,并打印一行致命的 `landlock-run:` 诊断。成功完成 exec 的子进程也可能返回 125,因此仅凭退出状态不能作为 launcher 失败的证据。较旧的 ABI 会在执行子进程之前打印精确的 `landlock-run: partial enforcement (older Landlock ABI)` 通知,因此该行不是致命证据。 -Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness 消费方同仓,并属于根 pnpm workspace。[仓库内 Landlock 发布决策](../process/2026-08-06-in-repository-landlock-release.md)负责共享锁文件、原生构建、打包演练和 npm 发布边界。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI(命令行界面)参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 +Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness 消费方同仓,并属于根 pnpm workspace。[仓库内 Landlock 发布决策](../process/2026-08-06-in-repository-landlock-release.zh.md)负责共享锁文件、原生构建、打包演练和 npm 发布边界。平台二进制由 npm 选择,入口包拥有路径解析、探测、CLI(命令行界面)参数、致命前缀和部分强制执行通知,而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。 后端 profile 共享模式约定但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`;workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement,而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。 @@ -72,7 +72,7 @@ Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness `dsh-bash-sandbox` 扩展 `LocalBashExecutor`,把精确的 `['bash', '-c', command]` argv 交给 `ctx.sandbox`,并直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。进程启动前,只有当调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn '`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn '`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构拒绝保留本地命令启动语义。前台执行会将 runner 失败转为 `SANDBOX_UNAVAILABLE` 并附上原始详细信息;异步后台拒绝则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,前台与后台共用一个 runner 失败分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详细信息;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。 -模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。 +模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](2026-07-30-current-sandbox-policy-context.zh.md)负责该上下文的理由与边界。 #### 升级机制:拒绝后一次经批准的更宽重试 @@ -92,7 +92,7 @@ Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness 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.zh.md))。 **每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`): @@ -103,7 +103,7 @@ interface SessionEventMap { } ``` -每个拥有者贡献同一套构成:事件声明,一个注册在必需注入的 `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) 同一模式的另一侧。 +每个拥有者贡献同一套构成:事件声明,一个注册在必需注入的 `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.zh.md))。执行在两侧都遵循投影状态——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.zh.md) 同一模式的另一侧。 每次拟议步骤之前,沙箱策略与批准策略都会渲染为一条目标策略上下文消息中的有序贡献。监听器将该消息与会话历史、已领取批次及其待处理的 `next-step` inbox 条目协调。消息一旦被领取并进入步骤,循环就会记录完整且带来源的 `user/message`;`'ask'` 与 `'never'` 都会明确写入,因此两个归属方都无需切换叙述或「上次告知」状态。 @@ -113,7 +113,7 @@ interface SessionEventMap { #### 进程内工具 -fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边界层面的策略。fs seam 现在通过沙箱提供方强制共享模式词汇(`dsh-fs-sandbox` 按模式限制 write/edit;见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.md)),因此 `read-only`/`workspace-write` 对文件系统工具也是真实边界,而非仅限 bash 的近似。web/todo 仍不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。没有通用的按工具沙箱运行时:主机中介的工具仅通过返回主机验证的声明式效果来离开进程,那是一次重写而非包装层——后续设计选择了一个共享策略归属 `ctx.sandboxPolicy`,由各能力强制,而不是统一包装层。 +fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边界层面的策略。fs seam 现在通过沙箱提供方强制共享模式词汇(`dsh-fs-sandbox` 按模式限制 write/edit;见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md)),因此 `read-only`/`workspace-write` 对文件系统工具也是真实边界,而非仅限 bash 的近似。web/todo 仍不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。没有通用的按工具沙箱运行时:主机中介的工具仅通过返回主机验证的声明式效果来离开进程,那是一次重写而非包装层——后续设计选择了一个共享策略归属 `ctx.sandboxPolicy`,由各能力强制,而不是统一包装层。 ### 测试 @@ -128,7 +128,6 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,由主仓库在 `native/` 下按 `@deepseek-ai/node-addon-landlock-run` 模板交付,加上其 profile 方言、拒绝签名和 runner 失败规则。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——它所经受的实战检验还不足以承载安全不变式。 ## 曾考虑的替代方案 @@ -150,7 +149,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **将重试硬匹配到先前的拒绝**:否决。命令字符串同一性脆弱(引号、`workdir`、env 前缀、作为失败阶段重试的管道)——要么误拒诚实的重试,要么被轻易满足;真正的边界是人看到命令 + 理由。仅在 `allow_always` 授权存储需要机器可检查的范围时才重新考虑。 - **通用 `env/state` facts map 加拥有者服务**:否决。approval 和沙箱独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 `findLast`,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。 - **通过 `agent.inject()` 加总线事件逐次叙述切换**:否决。独立通知会暴露归属方顺序和中间组合,而一次 pre-step 组合可以原子排队完整的当前状态。 -- **在稳定系统提示词中声明沙箱模式**:先行交付,随后根据线上证据移除:每次请求都带有 `Bash commands run under the "read-only" file sandbox.` 时,模型会拒绝尝试本可在被拒后升级的工作(首次人工会话的十二个轮次中有五个以零工具调用结束),使沙箱变成软锁死。拒绝标记会在相关时刻指出模式,升级字段则承载恢复路径。[当前策略决策](2026-07-30-current-sandbox-policy-context.md)取代了省略策略的决策;这项测量和因果观察仍是任何替代方案必须进行反证测试的依据。 +- **在稳定系统提示词中声明沙箱模式**:先行交付,随后根据线上证据移除:每次请求都带有 `Bash commands run under the "read-only" file sandbox.` 时,模型会拒绝尝试本可在被拒后升级的工作(首次人工会话的十二个轮次中有五个以零工具调用结束),使沙箱变成软锁死。拒绝标记会在相关时刻指出模式,升级字段则承载恢复路径。[当前策略决策](2026-07-30-current-sandbox-policy-context.zh.md)取代了省略策略的决策;这项测量和因果观察仍是任何替代方案必须进行反证测试的依据。 - **用专门的簿记事件追踪「上次告知」**:否决。会话历史记录模型看到的确切策略上下文,已领取批次与待处理 inbox 条目则表明正在进入或已经排队的内容。重新计算目标消息取代了第二条簿记流——事件仅在它们本身即为存储时才需要。 - **相互独立的沙箱与批准选择器**:否决。一个部署定义的权限 preset 让两个策略旋钮对暴露运行时切换的 UI 客户端保持一致。 @@ -170,8 +169,8 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 代价与已接受的限制: - **单一包装层的幻觉被有意放弃。**`tools/pre-execute` 包装层加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 -- **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 -- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.md)以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 +- **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 +- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 @@ -186,10 +185,10 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。相关指令禁止通过绕过限制来重试;唯一被认可的动作是以升级请求重试同一命令一次。 - **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。 -- **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn;`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。 +- **在没有后端的平台上会发生什么?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn。 - **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。 -- **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。 -- **哪些工具实际在约束下运行?** 通过 `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 的唯一效果是网络,不在文件效果模式词汇内)。 +- **沙箱限制网络或进程可见性吗?** `SandboxMode` 仅声称文件操作,没有后端声称网络。进程可见性取决于后端:bwrap 会 unshare PID 并挂载匹配的 procfs,因为宿主 `/proc/` 魔法链接会绕过文件约束;Landlock 与 Seatbelt 则保持进程可见性不变([决策](../bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md))。网络限制是否成为自己的旋钮留在 § seam 中开放。 +- **哪些工具实际在约束下运行?** 通过 `ctx.shell` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。 - **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `job_output` 呈现,并且可以作为一次新的精确命令重试的依据。 - **运行时模式切换何时生效?** 一旦其会话事件提交,下一次 pre-step 策略上下文协调与下一次能力解析都会折叠新模式。带来源的上下文消息会记录模型收到的内容,之后的任何拒绝都会在使用点命名同一策略。 - **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从持久化投影缓存恢复并在会话日志上重新折叠(`effective = 投影覆盖 ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值会进入下一条完整策略上下文消息。 @@ -199,9 +198,9 @@ 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(瀑布式事件)、审计对和单包理由记录在那里。 -- [会话投影作为必需 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 时刻)。 +- [能力 seam Agent Note](../architecture/2026-06-13-capability-seams.zh.md)——Service Definition/Service Provider/Consumer 拆分与「不要过早拆分」的时机规则(第二个消费方满足了该规则)。 +- `dsh-shell` 的 request/spec 拆分([bash 词汇目录](../../../../docs/subsystems/shell.zh.md))——完整的 `sandboxPolicy` 搭载其按调用载体,以及显式 `resolve()` 默认约定。 +- [批准 seam Agent Note](2026-07-06-approval-seam.zh.md)——升级请求通过的通道;其应答器 waterfall(瀑布式事件)、审计对和单包理由记录在那里。 +- [会话投影作为必需 seam](../architecture/2026-08-19-session-projection-mandatory-seam.zh.md)——旋钮单元注册所在的必需 `ctx.sessionProjections` 注册表;host 读取方经 `stateOf(session, key)` 读取折叠后的覆盖。 +- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.zh.md)与[独立纯日志事件](../simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)——投影单元折叠所依赖的日志即存储基础,以及锚定设计遵守的显式持久性边界。 +- [拦截扩展点 Agent Note](2026-06-30-interception-extension-points.zh.md)——`tools/pre-execute` 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。 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 b765bccf9e..2832c8e125 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: 3ddc2f83196513969307f94715f3626a6c45edde -2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 7a40bdf27608747d9a231b674648da85c083528d +2026-07-22-durable-subagent-catalog-and-list-agents.md: 813ad0a57bf5399cf641f8a7271671da57ce3966 +2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: ab5351647da32384645c036c0c5e3e2281ba645a diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md index 7a40bdf276..ab5351647d 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -可继续的后台 subagent 会公开稳定的 child id,并将重建数据持久化在该 child 的会话中,因此 `send_message` 无需任何列表查询操作即可恢复已知 child。发现功能有两类需求不同的消费方:UI 可以同时展示一次性工作和可继续对话,而模型只应收到适合使用 `send_message` 的 child。[可继续 subagent](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md)负责持久化 Session 与 Activation 设计;本记录负责共享的持久化清单及面向模型的投影。 +可继续的后台 subagent 会公开稳定的 child id,并将重建数据持久化在该 child 的会话中,因此 `send_message` 无需任何列表查询操作即可恢复已知 child。发现功能有两类需求不同的消费方:UI 可以同时展示一次性工作和可继续对话,而模型只应收到适合使用 `send_message` 的 child。[可继续 subagent](../../implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md)负责持久化 Session 与 Activation 设计;本记录负责共享的持久化清单及面向模型的投影。 枚举必须交叉核对不可变的会话谱系、描述符有效性与实时优先的会话语料,而不能仅为展示就加载或恢复 Agent。追踪谱系可以提供候选项,却无法区分普通会话 fork 与 subagent,因此 child 日志需要持久化分类。约定还必须定义生命周期模式、缺失或损坏的记录、删除、不受支持的版本,以及反复加载大量 child 日志会如何影响服务与工具消费方。 ## 决策 -**列表读路径已被取代。**[subagent 列表经投影单元读取身份](../architecture/2026-08-06-subagent-list-identity-projection.md)取代了本记录的枚举与逐 child 读取设计:`listChildren` 现在直接合并存活会话存储与可选的会话持久化,并从注册的 `subagent` projection unit 读取每个 child 的 mode/label——不依赖会话查询,也不在列表时扫描描述符;当前的列表语义(含 diagnostic 映射)以该记录为准。本记录仍是描述符持久化、以 mode 判别的描述符作为持久身份、直接 parent 鉴权与面向模型的 `list_agents` 投影的权威;下文基于追踪的读取机制是决策背景,不再是当前行为。 +**列表读路径已被取代。**[subagent 列表经投影单元读取身份](../architecture/2026-08-06-subagent-list-identity-projection.zh.md)取代了本记录的枚举与逐 child 读取设计:`listChildren` 现在直接合并存活会话存储与可选的会话持久化,并从注册的 `subagent` projection unit 读取每个 child 的 mode/label——不依赖会话查询,也不在列表时扫描描述符;当前的列表语义(含 diagnostic 映射)以该记录为准。本记录仍是描述符持久化、以 mode 判别的描述符作为持久身份、直接 parent 鉴权与面向模型的 `list_agents` 投影的权威;下文基于追踪的读取机制是决策背景,不再是当前行为。 parent 到 child 的枚举是一项带消费方专用投影的服务功能。`SubagentRuntime.listChildren(parentSessionId: SessionId)`([subagent/src/index.ts](../../../../packages/subagent/subagent/src/index.ts))执行以下操作: @@ -23,7 +23,7 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su - 将语料活动状态单独报告为 `running` 或 `inactive`,但不暗示已完成或可恢复; - 按 `createdAt` 升序、再按 child id 升序稳定返回所有结果 child。 -每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`,会将其用于一次性显示;workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child,并通过在线 Agent 注册表细化状态(`running`/`idle`,以及对应仅存于存储的 [`ready`](../bug-fix/2026-08-06-list-agents-residency-vocabulary.md));UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 约定负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。 +每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`,会将其用于一次性显示;workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child,并通过在线 Agent 注册表细化状态(`running`/`idle`,以及对应仅存于存储的 [`ready`](../bug-fix/2026-08-06-list-agents-residency-vocabulary.zh.md));UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 约定负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。 ### 枚举决策 @@ -35,7 +35,7 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su 已发布的逻辑记录同时也是活动状态来源:`SessionRecord.live` 表示 `running`,而 `live: false, persisted: true` 表示 `inactive`。活动状态直接来自追踪结果,不会导致额外加载 child 日志。`inactive` 既不表示执行成功,也不表示可恢复:它可能表示已结算的一次性历史,也可能表示 `send_message` 可以为其物化另一次 Activation 的可继续 child。反过来,`running` 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活可继续 Agent 仍会显示为 `running`,但 `send_message` 会将其作为所有权冲突拒绝。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或活动状态来源。列表查询是一份快照,可能与发布、dispose 或后续消息发生竞态;`send_message` 仍是消息送达时的权威操作。 -subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentRuntime` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时约定。这一段的依赖姿态——可选 `sessionQuery`、其错误码与列表工具的加载要求——同属被取代的读路径:现行列表约定以[取代记录](../architecture/2026-08-06-subagent-list-identity-projection.md)为准;在那里 `sessionProjections` 是 `SubagentRuntime` 的必需注入,`listChildren` 直接读取它,投影注册表缺失会在激活期失败而非列表调用时失败,`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`(会话存储缺席)仍是唯一的稳定可用性错误码。 +subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentRuntime` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时约定。这一段的依赖姿态——可选 `sessionQuery`、其错误码与列表工具的加载要求——同属被取代的读路径:现行列表约定以[取代记录](../architecture/2026-08-06-subagent-list-identity-projection.zh.md)为准;在那里 `sessionProjections` 是 `SubagentRuntime` 的必需注入,`listChildren` 直接读取它,投影注册表缺失会在激活期失败而非列表调用时失败,`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`(会话存储缺席)仍是唯一的稳定可用性错误码。 `listChildren(parentSessionId, signal?)` 会把调用方的取消信号转发给 `traceSession()` 和条件性精确 `readEvent()` 操作。`listEvents()` 不接受取消参数,因此列表查询路径会在等待该操作的前后,以及每个候选处理完成后检查信号。如果取消信号触发后有查询操作以拒绝结算,服务会将结果归一化为 `SubagentError`,并携带稳定错误码 `CANCELLED`;后端中止错误或可映射为 diagnostic 的查询错误均不会逃逸,也不会使调用以成功的部分列表返回。 @@ -52,7 +52,7 @@ subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务 有效描述符产生一个 child 条目,逐 child 检查失败产生一个 diagnostic 条目,缺少描述符的候选不产生条目。`mode` 是持久化创建策略;`activity` 是进程本地语料快照。活动状态既不是 `AgentStatus`、管理器内部的 Activation 状态,也不是持久化结果,结果不公开内部 `createdAt` 排序键。成功完成、失败、取消和停止原因等精确 Activation 状态与持久化结果需要单独的持久化激活记录,不在本功能范围内。 -面向模型的 `list_agents` 工具接受一个可选的 `scope: 'children' | 'descendants'` 参数,从当前执行 Agent 推导根 id,并在执行或渲染前通过显式的 request-to-spec 步骤解析请求(`undefined` → `children`)。解析后的 `children` scope 调用 `SubagentRuntime.listChildren(rootSessionId)`,`descendants` scope 则调用 `SubagentRuntime.listDescendants(rootSessionId)`。其内部输出投影中的 `id` 与 `parent` 会一直保持为品牌化的 `SessionId` 值,直到工具 JSON 边界。它保留 diagnostic,丢弃 `one-shot` child 条目,状态取自在线 Agent 注册表——driver 活跃为 `running`,驻留但处于轮次之间为 `idle`,没有在线 Agent 时为 `ready`(可恢复而非终态)——然后按稳定目录顺序渲染 ` [] —