Merge remote-tracking branch 'origin/master' into stack/agent-profiles-1-seam
This commit is contained in:
@@ -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-06-18-shared-persistence-write-coordinator.md
|
||||
2026-06-18-shared-persistence-write-coordinator.md: 66b73b60ceec9497f1f1226747b8cebd831eb426
|
||||
2026-06-18-shared-persistence-write-coordinator.zh.md: 424ce6ec7384e8af7b979a29f58c31379a1d1850
|
||||
2026-06-18-shared-persistence-write-coordinator.md: 12131ebf8380fb8ba816618f0cbaf72cb004623a
|
||||
2026-06-18-shared-persistence-write-coordinator.zh.md: a11926b602651d8a7dc641f61371e882685b508b
|
||||
|
||||
@@ -14,7 +14,7 @@ Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistenc
|
||||
|
||||
Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks and cannot reach the coordinator's private orchestration state. A third-party backend MAY still implement the abstract service directly without the coordinator, including immutable logical inspection and the default preparation fallback through `load`.
|
||||
|
||||
The coordinator holds one controller for each exact live `Session`; the controller combines initialization, pending events, and the shared flush promise. Each `session/event` starts an eager drain, and `session/flush` observes quiescence rather than initiating the ordinary write path. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns this lifecycle.
|
||||
The coordinator holds one lifecycle entry for each exact live `Session`: initialization plus a package-private write controller that owns pending events, a fixed batching deadline, the active write, failure retention, and the shared flush barrier. Each `session/event` enters that bounded write path, and `session/flush` bypasses the wait to observe quiescence. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns controller consolidation; the [bounded batching decision](2026-08-08-bounded-session-persistence-write-batching.md) owns scheduling cadence.
|
||||
|
||||
The coordinator retires a session from `session/disposed`: it waits for the controller's initialization and current flush, serializes a final drain, and removes the controller and owned per-id state only after success. A failure leaves the controller discoverable for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still current, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters write-path listeners, flushes every remaining controller, awaits per-id operations, and then closes the backend.
|
||||
|
||||
@@ -35,7 +35,7 @@ The single design choice that keeps the seam clean: the crash-repair "where is t
|
||||
|
||||
## Testing
|
||||
|
||||
The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` balances an interrupted logical view without changing storage or revisions before `prepare` or `load` commits recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. `persistence.spec.ts` and `preparations.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, eager follow-up batches, live-controller cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only. A through-coordinator torn-tail repair test per real backend keeps the opaque-marker branch covered because the contract crash case produces synthetic closers without a torn marker.
|
||||
The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` balances an interrupted logical view without changing storage or revisions before `prepare` or `load` commits recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. `persistence.spec.ts`, `preparations.spec.ts`, and `write-behind.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, fixed-window follow-up batches, live-controller cleanup, same-id chain-tail races, failed-batch retry, and close ordering. The per-backend specs retain storage mechanics only. A through-coordinator torn-tail repair test per real backend keeps the opaque-marker branch covered because the contract crash case produces synthetic closers without a torn marker.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -44,4 +44,4 @@ The shared `runPersistenceContract` (public-API contract) runs for every backend
|
||||
|
||||
## Consequences
|
||||
|
||||
The coordinator adds one indirection, an opaque torn marker, detached session-retirement tasks, and bounded prepared Session state, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, preparation, and immutable inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn does not commit interruption closers; the [Session preparation decision](2026-08-05-session-preparation.md) owns reuse, reservation, and publication. New backends implement storage primitives rather than copy the eager write lifecycle.
|
||||
The coordinator adds one indirection, an opaque torn marker, detached session-retirement tasks, and bounded prepared Session state, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves pending events in the live controller, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, preparation, and immutable inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn does not commit interruption closers; the [Session preparation decision](2026-08-05-session-preparation.md) owns reuse, reservation, and publication. New backends implement storage primitives rather than copy the bounded write lifecycle.
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 Agent Note 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。
|
||||
|
||||
协调器为每个存活的 `Session` 实例持有一个控制器;该控制器统合初始化、待处理事件与共享 flush promise。每个 `session/event` 都会立即启动排空,而 `session/flush` 只观察完全停稳,不会发起常规写入路径。[flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义该生命周期。
|
||||
协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.md)定义。
|
||||
|
||||
协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。
|
||||
|
||||
@@ -35,7 +35,7 @@ Status: implemented
|
||||
|
||||
## 测试
|
||||
|
||||
共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare` 或 `load` 提交恢复。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空和崩溃尾部修复。`persistence.spec.ts` 与 `preparations.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、立即执行的后续批次、存活控制器清理、同 id 链尾竞态、排空失败重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers,却不会产生 torn marker。
|
||||
共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare` 或 `load` 提交恢复。`runCoordinatorContract`(`tests/coordinator-contract.ts`)通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空和崩溃尾部修复。`persistence.spec.ts`、`preparations.spec.ts` 与 `write-behind.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、固定窗口后续批次、存活控制器清理、同 id 链尾竞态、失败批次重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers,却不会产生 torn marker。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -44,4 +44,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断 closers;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.md)定义。新后端只需实现存储原语,而无需复制立即写入生命周期。
|
||||
协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断 closers;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.md)定义。新后端只需实现存储原语,而无需复制有界写入生命周期。
|
||||
|
||||
@@ -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-06-21-mandatory-app-attribution-headers.md
|
||||
2026-06-21-mandatory-app-attribution-headers.md: a8ffe91c431cdc7907626bbc3eaf8096035777de
|
||||
2026-06-21-mandatory-app-attribution-headers.zh.md: ac4affce583d5d81253f320ff022f670d4d66cc8
|
||||
2026-06-21-mandatory-app-attribution-headers.md: ad9d65805c8f0c96bd811b5036310d019760627e
|
||||
2026-06-21-mandatory-app-attribution-headers.zh.md: 3021c7fcca00f2e929d997625c303f9a27dbf673
|
||||
|
||||
@@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri
|
||||
|
||||
- product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity)
|
||||
- version: read from the owning package's manifest via `createRequire`, never a hand-copied constant
|
||||
- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; a `FIXME` in `attribution.ts` blocks release until that repository actually exists
|
||||
- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home, which must exist before release
|
||||
|
||||
The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override seam is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields.
|
||||
|
||||
@@ -77,7 +77,7 @@ The landed contract:
|
||||
|
||||
**Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`.
|
||||
|
||||
**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. The `FIXME` marker on the constant blocks a release from shipping with it unresolved (see `docs/development.md` marker semantics).
|
||||
**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise that blocks release.
|
||||
|
||||
**Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这
|
||||
|
||||
- `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性)
|
||||
- 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量
|
||||
- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布
|
||||
- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页,且必须在发布前实际存在
|
||||
|
||||
默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。
|
||||
|
||||
@@ -77,7 +77,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这
|
||||
|
||||
**提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。
|
||||
|
||||
**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,不允许带着未解决的问题出门(见 `docs/development.md` 标记语义)。
|
||||
**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个阻塞发布的悬空承诺。
|
||||
|
||||
**不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 契约。
|
||||
|
||||
|
||||
@@ -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-07-02-tool-render-intent-union.md
|
||||
2026-07-02-tool-render-intent-union.md: d82141f519bff66df000f1316093aacd38b8e42b
|
||||
2026-07-02-tool-render-intent-union.zh.md: e145908e019e71765a475b0ca9d22414e9b42d23
|
||||
2026-07-02-tool-render-intent-union.md: 67607b2848305439513503d7e03ad5e2a2e4020a
|
||||
2026-07-02-tool-render-intent-union.zh.md: 9cbab75ce87f6d313ca4b6ac8dda043733361f0d
|
||||
|
||||
@@ -14,7 +14,7 @@ A tool declares how its calls render in a UI (an editor's tool-call card) throug
|
||||
- Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense.
|
||||
- There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff.
|
||||
|
||||
The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." An earlier rejected collapse-tool-owned-presentation proposal deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers.
|
||||
An earlier rejected collapse-tool-owned-presentation proposal deferred rich rendering until it could "return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers.
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
- 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。
|
||||
- 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。
|
||||
|
||||
`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」一个早先被否决的折叠工具自有呈现提案明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。
|
||||
一个早先被否决的折叠工具自有呈现提案把富渲染推迟到它能够「在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归」之时。该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。
|
||||
|
||||
## 决策
|
||||
|
||||
|
||||
@@ -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-07-05-prompt-variables-and-tool-guidance-ownership.md
|
||||
2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 94f5fa409e7b539b48750d12576c7a342a30c9ba
|
||||
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 341b3a89f423c9cc7d2fc56f1ea25a1985680d0d
|
||||
2026-07-05-prompt-variables-and-tool-guidance-ownership.md: a3b5021daf323971308760bde4f97651db8edbda
|
||||
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 490d41302ea4f4e14e11e301acbf47170f95cace
|
||||
|
||||
@@ -10,7 +10,7 @@ The assembled system prompt had four defects, all of one family: facts the harne
|
||||
|
||||
**The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all.
|
||||
|
||||
**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the old terminal welcome banner hand-enumerated the tool set too.
|
||||
**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand, and the old terminal welcome banner hand-enumerated the tool set too.
|
||||
|
||||
**The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
**模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何提示词文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。
|
||||
|
||||
**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona;两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉,旧终端欢迎横幅也手动枚举了工具集。
|
||||
**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 coding-agent 和 ACP persona 字符串里——两份漂移的副本(ACP 那份已经被删减)——而 `dsh-tool-fs` 和 `dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona,旧终端欢迎横幅也手动枚举了工具集。
|
||||
|
||||
**Persona 渲染在工具指导之后。** agent loop(智能体循环)将 `agent.options.systemPrompt` 字符串拼接在已组装的 section 之后,于是模型先读到「Use the read tool…」再读到「You are a coding agent」——与 identity-first 约定(Claude Code、Codex)相反,且是 section 流水线之外的第二条组合路径。
|
||||
|
||||
|
||||
@@ -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-07-05-reconstructable-requests.md
|
||||
2026-07-05-reconstructable-requests.md: 2f559a3052b9fb84f788975a64799e4f020b0d3e
|
||||
2026-07-05-reconstructable-requests.zh.md: 26abdc024a166856e51ebf09f086c7868fc8236d
|
||||
2026-07-05-reconstructable-requests.md: ebca9b99cad791159302da9c2bbce9f4df147aab
|
||||
2026-07-05-reconstructable-requests.zh.md: 91ef3fd04502f2c2f092da60a8bd909cf7fa25df
|
||||
|
||||
@@ -53,4 +53,3 @@ Like MiniCode, the conversation advances append-only and resets only when model-
|
||||
- Tool-result trimming (planned) needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
|
||||
- Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated.
|
||||
- Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths.
|
||||
- FIXME(call-config-shape): revisit `LlmCallConfig`'s exact field set — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit there out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them.
|
||||
|
||||
@@ -53,4 +53,3 @@ Status: implemented
|
||||
- 工具结果裁剪(计划中)无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。
|
||||
- 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。
|
||||
- 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
|
||||
- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问;采样标量出于谨慎放在那里),以及当适配器需要时,提供方特定的额外项(推理(reasoning)选项、额外 body 参数)应归属何处。
|
||||
|
||||
@@ -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-07-16-explicit-turn-cancellation.md
|
||||
2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3
|
||||
2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943
|
||||
2026-07-16-explicit-turn-cancellation.md: 2b7cb8cc77184edf1331764d28aefe748c1614a1
|
||||
2026-07-16-explicit-turn-cancellation.zh.md: 68089e2c48d239afbff4c10cba5a202b4b6ff262
|
||||
|
||||
@@ -18,7 +18,7 @@ An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outc
|
||||
|
||||
AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through inbox claim, `agent/pre-step`, prompt assembly, every step, model and tool execution, and `agent/turn-stopping`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal.
|
||||
|
||||
The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work.
|
||||
The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer is latched and runs when the aborted activity converges to idle — a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work.
|
||||
|
||||
The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user
|
||||
|
||||
AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。
|
||||
|
||||
对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
|
||||
对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
|
||||
|
||||
显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。
|
||||
|
||||
|
||||
@@ -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-07-19-gui-layering-and-rpc-protocol.md
|
||||
2026-07-19-gui-layering-and-rpc-protocol.md: 34077302c53081f6ee9171d64dce9af342710d71
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: bc51542ac8159ee7cba234b4ee8b4db47a7f9b58
|
||||
2026-07-19-gui-layering-and-rpc-protocol.md: 8e020e4fe9b60100671c0cf0e98e28532d850f94
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: 55fa8084083aa83fbb4a38f8e41d2e5624b6e58e
|
||||
|
||||
@@ -10,7 +10,7 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md)
|
||||
|
||||
We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities:
|
||||
|
||||
- One `dsh` process supporting both `dsh web` (serve) and `dsh -p` (headless) — one process, two modes (a design reservation)
|
||||
- One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation)
|
||||
- Launching inside Electron with the same Web technology shape as `dsh web`
|
||||
|
||||
That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly.
|
||||
@@ -31,7 +31,7 @@ Directories layer as follows:
|
||||
- **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services.
|
||||
- `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures.
|
||||
- `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`.
|
||||
- `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh -p` = headless in-process calls, zero HTTP.
|
||||
- `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh run` = headless in-process calls, zero HTTP.
|
||||
- A future Electron shape reuses the same web client packages over an IPC fetch carrier.
|
||||
|
||||
```
|
||||
@@ -215,7 +215,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation
|
||||
|
||||
| Subclass | Package | doFetch | Purpose |
|
||||
|---|---|---|---|
|
||||
| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer |
|
||||
| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh run` headless is the protocol's second real consumer |
|
||||
| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) |
|
||||
| `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) |
|
||||
| (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged |
|
||||
|
||||
@@ -9,7 +9,7 @@ Status: implemented
|
||||
## Problem
|
||||
|
||||
需要提供 UI 对接层,除已有 ACP/stdio基础版本外,还需要 Web(server) 、 Electron 、等其他产品 UI 形态。我们把这些形态统一称为 Client。希望有如下能力支持:
|
||||
- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh -p`(headless) ,一个进程两种模式(设计预留)
|
||||
- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh run`(headless) ,一个进程两种模式(设计预留)
|
||||
- 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动
|
||||
|
||||
那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。
|
||||
@@ -29,7 +29,7 @@ Status: implemented
|
||||
- **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。
|
||||
- `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。
|
||||
- `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。
|
||||
- `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh -p` = headless 进程内直调,零 HTTP。
|
||||
- `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = headless 进程内直调,零 HTTP。
|
||||
- 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。
|
||||
|
||||
```
|
||||
@@ -213,7 +213,7 @@ export type ResponseValue<K> =
|
||||
|
||||
| 子类 | 所在包 | doFetch | 用途 |
|
||||
|---|---|---|---|
|
||||
| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 |
|
||||
| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh run` headless 即协议第二真实消费者 |
|
||||
| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) |
|
||||
| `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) |
|
||||
| (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,契约/基类零改 |
|
||||
|
||||
@@ -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-07-19-gui-web-client-architecture.md
|
||||
2026-07-19-gui-web-client-architecture.md: 1a91d88818c374a1637b546fb3ddf6647af68570
|
||||
2026-07-19-gui-web-client-architecture.zh.md: 5c0bacde9836d45812895f5d9c89a0e8974ed7a1
|
||||
2026-07-19-gui-web-client-architecture.md: e25d002b92df2016faf4073108aa905b65a1b115
|
||||
2026-07-19-gui-web-client-architecture.zh.md: cf31220aad61e4845f7fe90b73bf5da4432e53de
|
||||
|
||||
@@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien
|
||||
|
||||
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`; the declaration is the load and reload dependency, independently from `ConversationService` ([decision](2026-08-05-slot-declaration-injection.md)). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early.
|
||||
There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Tool presentation crosses one explicit package boundary: Runtime projects Code Dispatch topology into each root's recursive `subCalls`; ui-conversation places that ordered root into the single `'conversation.chat.tool'` seat without interpreting Tool names or topology; ui-tool renders the supplied tree and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>', inject? }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components.
|
||||
|
||||
**Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).
|
||||
|
||||
@@ -86,22 +86,24 @@ The glue package is the whole ctx↔React boundary; components stay framework-fr
|
||||
|
||||
## Directory shape
|
||||
|
||||
Twelve `packages/client/*` packages (ui-slots, ui-primitives, web-react, connection, runtime, ui-layout, ui-sidebar, ui-conversation, ui-trajectory, ui-theme, i18n, web) plus `apps/web` — the vite application, a thin `main` over the shell's boot export. Plugin packages keep their browser half under `src/client/`; **every build artifact lands in `lib/`** — the node half as `lib/index.js`/`lib/invariant.js`, the browser bundle as `lib/client.js` (the shared tsdown client preset emits both; there is no `dist/` directory, and `exports["./client"]` points at `./lib/client.js`). Dependency direction: `ui-slots ← web-react ← runtime ← ui-* (peers) ← web`, with ui-primitives/ui-theme/i18n as zero-dependency side paths.
|
||||
Client packages live under `packages/client/*`, with `apps/web` as the thin Vite application over the shell's boot export. Plugin packages keep their browser half under `src/client/`; **every build artifact lands in `lib/`** — the node half as `lib/index.js`/`lib/invariant.js`, the browser bundle as `lib/client.js` (the shared tsdown client preset emits both; there is no `dist/` directory, and `exports["./client"]` points at `./lib/client.js`). `ui-slots`, web-react, and runtime form the infrastructure direction; feature plugins cooperate through services and slots rather than importing presentation implementations.
|
||||
|
||||
A multi-domain plugin package additionally splits its client half by future package boundaries — ui-conversation is the exemplar:
|
||||
|
||||
```
|
||||
src/client/
|
||||
contract/ the only shared face between domains (types + composed props shares)
|
||||
service.ts cross-domain orchestration (imports contract only)
|
||||
skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel)
|
||||
chat/ domain: the chat view
|
||||
toolviews/ domain: sample tool-row registrants (third-party posture)
|
||||
apply.ts the ONLY file allowed to import across domains (assembly point)
|
||||
index.ts thin re-export shell (contract + apply + components)
|
||||
contract/ shared slot and cross-domain types
|
||||
service.ts cross-domain orchestration
|
||||
skeleton/ conversation shell and details host
|
||||
chat/ ordered conversation view
|
||||
input/ composer state machine
|
||||
queue/ queued-message presentation
|
||||
settings/ conversation settings rows
|
||||
apply.ts cross-domain assembly point
|
||||
index.ts public contract surface
|
||||
```
|
||||
|
||||
Domain implementation files never import a sibling domain — shared surfaces route through `contract/` (e.g. the toolviews samples take `ToolRowProps` from the contract, never chat internals). `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths.
|
||||
Domain implementation files never import a sibling domain; shared surfaces route through `contract/`. `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). Tool presentation is already a separate `ui-tool` package and reaches chat and details only through the slots ui-conversation declares.
|
||||
|
||||
## How to develop
|
||||
|
||||
@@ -122,5 +124,5 @@ Token streams no longer shake the render tree: a frame storm costs unsubscribed
|
||||
| One statically-linked SPA bundle | Plugins must be host-composable at runtime (config-driven); a monolith re-couples every UI feature to one build |
|
||||
| window globals / import maps for shared deps | The DI require table keeps sharing explicit, fail-loud, and swappable; globals leak identity and version silently |
|
||||
| Business data in zustand slices | The event window/accumulator is a behavioral state machine, not a flat slice; the object layer keeps snapshot granularity and batching controllable |
|
||||
| String-keyed global component registry for tool rows | Per-view keyed child slots plus in-component session branching carry the same need with the one registration model; a parallel registry does not come back ([toolview dissolution](2026-07-23-toolview-dissolution.md)) |
|
||||
| Parallel string-keyed component registry for Tool rows | ui-tool's keyed child slot carries the runtime-open Tool-name set through the one slot registration model ([toolview dissolution](2026-07-23-toolview-dissolution.md)) |
|
||||
| Progressive/Suspense boot in P-I | One-flip boot is strictly simpler; the loader's per-plugin status face is kept so progressive lighting can land later without re-architecture |
|
||||
|
||||
@@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-
|
||||
|
||||
服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
|
||||
|
||||
slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`;声明本身就是加载与重载依赖,不依赖 `ConversationService`([决策](2026-08-05-slot-declaration-injection.md))。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。
|
||||
slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。Tool 展示跨越一条显式包边界:运行时把 Code Dispatch 拓扑投影进每个 root 递归的 `subCalls`;ui-conversation 把这个已排序 root 放进 single `'conversation.chat.tool'` seat,不解释 Tool 名称或拓扑;ui-tool 渲染传入的树,并声明 keyed/session 的 `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>', inject? }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托选中调用的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。
|
||||
|
||||
**scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。
|
||||
|
||||
@@ -86,22 +86,24 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
|
||||
|
||||
## 目录形态
|
||||
|
||||
十二个 `packages/client/*` 包(ui-slots、ui-primitives、web-react、connection、runtime、ui-layout、ui-sidebar、ui-conversation、ui-trajectory、ui-theme、i18n、web)加 `apps/web`——vite 应用,壳 boot 导出之上的薄 `main`。插件包的浏览器半边在 `src/client/` 下;**一切构建产物落 `lib/`**——node 半边为 `lib/index.js`/`lib/invariant.js`,浏览器 bundle 为 `lib/client.js`(共享 tsdown client 预设两者皆出;无 `dist/` 目录,`exports["./client"]` 指向 `./lib/client.js`)。依赖方向:`ui-slots ← web-react ← runtime ← ui-*(并列)← web`,ui-primitives/ui-theme/i18n 为零依赖旁路。
|
||||
Client 包位于 `packages/client/*`,`apps/web` 是壳 boot 导出之上的薄 Vite 应用。插件包的浏览器半边在 `src/client/` 下;**一切构建产物落 `lib/`**——node 半边为 `lib/index.js`/`lib/invariant.js`,浏览器 bundle 为 `lib/client.js`(共享 tsdown client 预设两者皆出;无 `dist/` 目录,`exports["./client"]` 指向 `./lib/client.js`)。`ui-slots`、web-react 与 runtime 构成基础设施方向;功能插件通过 service 与 slot 协作,不导入展示实现。
|
||||
|
||||
多域插件包的 client 半边还按未来包边界再拆——ui-conversation 即样板:
|
||||
|
||||
```
|
||||
src/client/
|
||||
contract/ the only shared face between domains (types + composed props shares)
|
||||
service.ts cross-domain orchestration (imports contract only)
|
||||
skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel)
|
||||
chat/ domain: the chat view
|
||||
toolviews/ domain: sample tool-row registrants (third-party posture)
|
||||
apply.ts the ONLY file allowed to import across domains (assembly point)
|
||||
index.ts thin re-export shell (contract + apply + components)
|
||||
contract/ shared slot and cross-domain types
|
||||
service.ts cross-domain orchestration
|
||||
skeleton/ conversation shell and details host
|
||||
chat/ ordered conversation view
|
||||
input/ composer state machine
|
||||
queue/ queued-message presentation
|
||||
settings/ conversation settings rows
|
||||
apply.ts cross-domain assembly point
|
||||
index.ts public contract surface
|
||||
```
|
||||
|
||||
域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 toolviews 样例从契约取 `ToolRowProps`,永不碰 chat 内部)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2;import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。
|
||||
各领域实现文件不 import 兄弟领域;共享面统一经过 `contract/`。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、domain=1、apply/index=2;import 只准指向不高于自身的层级;兄弟领域依赖会失败)。Tool 展示已经拆为独立 `ui-tool` 包,只通过 ui-conversation 声明的 slot 到达 chat 与 details。
|
||||
|
||||
## 怎么开发
|
||||
|
||||
@@ -122,5 +124,5 @@ token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位
|
||||
| 静态链接的单 SPA bundle | 插件必须由 host 在运行时按配置组合;单体把每个 UI 功能重新耦回一次构建 |
|
||||
| window 全局变量 / import map 供共享依赖 | DI require 表让共享显式、大声失败、可替换;全局变量静默泄漏身份与版本 |
|
||||
| 业务数据进 zustand 切片 | 事件窗口/累积器是行为状态机,不是扁平切片;对象层保住快照粒度与合批的可控性 |
|
||||
| 工具行走字符串键的全局组件注册表 | per-view keyed 子槽 + 组件内会话分支以唯一注册模型承载同一需求;平行 registry 不复活([toolview 溶解](2026-07-23-toolview-dissolution.md)) |
|
||||
| Tool 行使用平行的字符串键组件注册表 | ui-tool 的 keyed 子 slot 通过唯一的 slot 注册模型承载运行时开放的 Tool 名称集合([toolview 溶解](2026-07-23-toolview-dissolution.md)) |
|
||||
| P-I 就做渐进/Suspense 启动 | 一次成型严格更简单;loader 的按插件状态面已保留,渐进点亮日后可落地而无需重构 |
|
||||
|
||||
@@ -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-07-23-toolview-dissolution.md
|
||||
2026-07-23-toolview-dissolution.md: 97d8beb4de43d9bc6348d942e5460d0321592b32
|
||||
2026-07-23-toolview-dissolution.zh.md: db93c6252d5d42d1fd85ce81ad430d95f4324cf2
|
||||
2026-07-23-toolview-dissolution.md: be1bd9d161714194855988d76a632c667fae8c84
|
||||
2026-07-23-toolview-dissolution.zh.md: afe5e03e8345fca2a0f097d86873974f6d417ff2
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-toolview-dissolution.zh.md)
|
||||
|
||||
> Scope: why the standalone tool ring (ToolViewRegistry/ctx.toolviews/outlet) was retired and what replaced it. The [web client architecture note](2026-07-19-gui-web-client-architecture.md) carries the shipped-state narrative this decision produced; the [slot system standard](2026-07-22-slot-type-chain-implementation.md) owns the registration model everything now runs on.
|
||||
> Scope: why the standalone tool ring (ToolViewRegistry/ctx.toolviews/outlet) was retired and what replaced it. The [web client architecture note](2026-07-19-gui-web-client-architecture.md) carries the shipped-state narrative this decision produced; the [slot system standard](2026-07-22-slot-type-chain-implementation.md) owns the registration model everything now runs on. The later [Client Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) decision supersedes only this note's per-view placement: Tool-name dispatch remains a keyed slot rather than a parallel registry.
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -14,24 +14,22 @@ After the view ring dissolved into the slot system, the client kept exactly one
|
||||
|
||||
The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively.
|
||||
|
||||
Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin using `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`; the declaration itself governs activation and replacement, without a false `ConversationService` edge ([decision](2026-08-05-slot-declaration-injection.md)). The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations.
|
||||
|
||||
Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option.
|
||||
This decision originally placed `'conversation.chat.toolview'` under the chat entry and made the chat render site dispatch each row. The follow-up [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) moves that placement into a whole-Tool seat and gives `ui-tool` one keyed `'tool.call.toolview'` child slot. That follow-up changes the presentation owner, not this decision's core constraint: Tool registration continues to use ordinary keyed-slot machinery, with framework-owned activation, replacement, caching, error isolation, versioning, and fallback behavior.
|
||||
|
||||
## Accepted semantic changes
|
||||
|
||||
Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance is per-view registration — a row must adapt to each view's layout anyway, so one registration per view is the correct coupling, and reuse is the same component in two register calls. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch, when a row needs it, belongs inside the component (the standard kit already carries `useSessions`), not in registry predicates — there is no shipped session-variant exemplar today. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry.
|
||||
Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance was initially per-view registration; the follow-up note records why root/subcall composition later justified one Tool-wide presentation owner. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch, when a row needs it, belongs inside the component (the standard kit already carries `useSessions`), not in registry predicates — there is no shipped session-variant exemplar today. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the standalone registry (the original shape).** Rejected: each of its multi-dimensional dispatch axes has a more correct home — the view dimension belongs to each view's own declared child slot (declaring is claiming, so specialization ownership lands right), and the session dimension belongs inside the component, which already holds the standard kit. What remained after both moves was a second copy of slot machinery with no distinguishing capability.
|
||||
**Keep the standalone registry (the original shape).** Rejected: each of its multi-dimensional dispatch axes has a more correct home — presentation ownership belongs to an explicitly declared child slot, and the session dimension belongs inside the component, which already holds the standard kit. What remains is a second copy of slot machinery with no distinguishing capability.
|
||||
|
||||
**Promote `renderToolView` into the standard kit and move the registry into the runtime package.** Rejected: "tool row" is a conversation-domain concept; hoisting it into runtime would leak a domain vocabulary into the framework layer and still leave two registration models.
|
||||
**Promote `renderToolView` into the standard kit and move the registry into the runtime package.** Rejected: Tool presentation is Client UI vocabulary; hoisting it into runtime would leak presentation into the data object layer and still leave two registration models.
|
||||
|
||||
**Derive slot declarations from subscription refCounts** (declare the slot implicitly when the first registrant subscribes). Rejected for implicit coupling and debounce complexity; noted as a possible revisit only if a genuinely multi-viewer surface appears.
|
||||
|
||||
**A thin `registerToolView` facade over slots.register.** Deferred, not rejected: after dissolution the facade would carry only compile-time sugar (slot-name literal narrowing, tool→key vocabulary, props pre-composition) with zero runtime. Per "enforce at the operation boundary" (a facade is not an enforcement point) and "don't split preemptively" (today's registrant population is one bash sample), it stays unbuilt; the type sugar ships as the exported `ToolRowProps` alias. Regret clause: if registrants grow to three-to-five or a bulk-registration pattern appears, the facade is ten lines added without disturbing direct registration.
|
||||
**A thin `registerToolView` facade over slots.register.** Deferred, not rejected: after dissolution the facade would carry only compile-time sugar (slot-name literal narrowing, tool→key vocabulary, props pre-composition) with zero runtime. Per "enforce at the operation boundary" (a facade is not an enforcement point), it stays unbuilt; the useful type composition ships as the exported Tool view props alias. A later facade can be added without disturbing direct registration if repeated registration ceremony justifies it.
|
||||
|
||||
## Consequences
|
||||
|
||||
The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override). Independent registrants name the typed slot in `ctx.slots.inject`, so the dependency is explicit and follows declaration replacement without a service-order convention.
|
||||
The client has one registration model; auditing who renders Tool calls means reading slot register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above, chiefly loud duplicate-key failure and no third-party registry-level override. Independent registrants name the typed slot in `ctx.slots.inject`, so the dependency is explicit and follows declaration replacement without a service-order convention.
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
[English](2026-07-23-toolview-dissolution.md) | 中文
|
||||
|
||||
> 范围:独立工具环(ToolViewRegistry/ctx.toolviews/outlet)为何退役、被什么取代。本决策产出的落地态叙述归 [Web 客户端架构注](2026-07-19-gui-web-client-architecture.md);一切现在所运行其上的注册模型归 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 所有。
|
||||
> 范围:独立工具环(ToolViewRegistry/ctx.toolviews/outlet)为何退役、被什么取代。本决策产出的落地态叙述归 [Web 客户端架构注](2026-07-19-gui-web-client-architecture.md);一切现在所运行其上的注册模型归 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md)所有。后续的 [Client Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.md)决策仅取代本篇的 per-view 放置方式:Tool 名称分发仍使用 keyed slot,而非平行注册表。
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -14,24 +14,22 @@ Status: implemented
|
||||
|
||||
工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。
|
||||
|
||||
落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方是使用 `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))` 的普通插件;声明本身控制激活与替换,不再引入虚假的 `ConversationService` 依赖([决策](2026-08-05-slot-declaration-injection.md))。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。
|
||||
|
||||
registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。
|
||||
本决策最初把 `'conversation.chat.toolview'` 放在 chat 条目下,由 chat 渲染点逐行分发。后续的 [Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.md)引入整体 Tool 席位,并让 `ui-tool` 拥有唯一的 keyed `'tool.call.toolview'` 子 slot。后续决策改变的是展示所有者,而非本决策的核心约束:Tool 注册继续使用普通 keyed-slot 机制,激活、替换、缓存、错误隔离、版本与 fallback 行为仍归框架所有。
|
||||
|
||||
## 接受的语义变化
|
||||
|
||||
四项行为增量是刻意接受而非疏漏。跨视图出场=逐视图注册——行本须适配各视图版式,一视图一注册是正确耦合,复用即同一组件写两次 register。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发若行需要,归组件内部(标配 kit 已带 `useSessions`),不走注册表谓词——今天没有已落地的会话变体样例。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。
|
||||
四项行为增量是刻意接受而非疏漏。跨视图出场最初采用逐视图注册;后续 Note 记录了 root/subcall 编排为何足以支持一个 Tool 级展示所有者。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发若行需要,归组件内部(标配 kit 已带 `useSessions`),不走注册表谓词——今天没有已落地的会话变体样例。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留独立注册表(原形态)。** 拒绝:其多维分发的每一维都有更正确的家——视图维归各视图自己声明的子槽(declaring is claiming,特化面权属自然落对),会话维归已持有标配 kit 的组件内部。两步移完后剩下的只是一份没有任何独有能力的 slot 机器副本。
|
||||
**保留独立注册表(原形态)。** 拒绝:其多维分发的每一维都有更正确的家——展示所有权归显式声明的子 slot,会话维归已持有标配 kit 的组件内部。两步移完后剩下的只是一份没有任何独有能力的 slot 机器副本。
|
||||
|
||||
**把 `renderToolView` 提进标配 kit、注册表迁入 runtime 包。** 拒绝:「工具行」是 conversation 域概念;上提进 runtime 会把域词汇泄漏进框架层,且依然留着两套注册模型。
|
||||
**把 `renderToolView` 提进标配 kit、注册表迁入 runtime 包。** 拒绝:Tool 展示是 Client UI 词汇;上提进 runtime 会把展示概念泄漏进数据对象层,且依然留着两套注册模型。
|
||||
|
||||
**以订阅 refCount 推导槽声明**(首个注册方订阅时隐式声明槽)。拒绝:隐式耦合加去抖复杂度;记为将来真出现多观看面时的备选。
|
||||
|
||||
**slots.register 之上的薄 `registerToolView` 门面。** 缓建而非拒绝:溶解后该门面只剩编译期三糖(槽名字面量收窄、tool→key 词汇翻译、props 预组合),运行时为零。按「enforce at the operation boundary」(门面不是强制点)与「don't split preemptively」(今天注册方人口只有一个 bash 样例)保持不建;类型糖以导出的 `ToolRowProps` 别名兑现。后悔药条款:注册方长到三五家或出现批量注册模式时,门面十行可补,不扰直注。
|
||||
**slots.register 之上的薄 `registerToolView` 门面。** 缓建而非拒绝:溶解后该门面只剩编译期语法糖(slot 名字面量收窄、tool→key 词汇翻译、props 预组合),运行时为零。按「enforce at the operation boundary」(门面不是强制点)保持不建;有用的类型组合以导出的 Tool view props 别名兑现。若重复注册仪式今后足以证明其价值,可在不扰动直接注册的前提下补充门面。
|
||||
|
||||
## Consequences
|
||||
|
||||
client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖)。独立注册方在 `ctx.slots.inject` 中点名有类型约束的 slot,因此依赖关系既显式,又能跟随声明替换,无需服务顺序约定。
|
||||
client 只有一种注册模型;审计谁渲染 Tool 调用就是读 slot register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化,主要是重复 key 会 loud failure,且第三方无 registry 级覆盖。独立注册方在 `ctx.slots.inject` 中点名有类型约束的 slot,因此依赖关系既显式,又能跟随声明替换,无需服务顺序约定。
|
||||
|
||||
@@ -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
|
||||
2026-07-27-compiler-independent-typert-model.md: 338476924dfb5d9832d0b64bf01b8d3c297cd6d6
|
||||
2026-07-27-compiler-independent-typert-model.zh.md: a88f4dbba50696071552ea12a63b69ecac202418
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md
|
||||
2026-07-27-compiler-independent-typert-model.md: 15641e5f785c5d2daecbfc2d5cffedd64d8384a7
|
||||
2026-07-27-compiler-independent-typert-model.zh.md: 7bddd3ffde343346c163f55ef493c32bad1e8314
|
||||
|
||||
@@ -22,7 +22,7 @@ PackageModel recognizes Cordis services, events, `@typert object` reference obje
|
||||
|
||||
[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) provides `ctx.typert` and handles runtime registration only: one contribution atomically carries package-face reflection and an optional Zod schema, and Cordis effect disposal revokes it. The registry neither analyzes TypeScript nor merges the two faces. JSON Schema is an on-demand projection of registered Zod schemas.
|
||||
|
||||
Package artifact publication is explicit opt-in. When invoked, `WorkspaceTypertGenerator` validates that each requested host face exposes the user-facing subpath `package/typert` from the root artifact `package/lib/typert.host.{js,d.ts}`, or that each requested client face exposes `package/client/typert` from `package/lib/typert.client.{js,d.ts}`. It neither edits exports nor runs as part of the ordinary root build or typecheck, so those commands do not generate whole-workspace Typert artifacts. Generated declarations keep `TYPERT` typed as `unknown`, so business packages do not depend on the registry.
|
||||
Package artifact publication remains explicit opt-in through package exports. When invoked, `WorkspaceTypertGenerator` validates that each requested host face exposes the user-facing subpath `package/typert` from the root artifact `package/lib/typert.host.{js,d.ts}`, or that each requested client face exposes `package/client/typert` from `package/lib/typert.client.{js,d.ts}`; it never edits those exports. The later [TypeRT Remote design](2026-08-02-typert-remote-method-calls.md) adds a whole-workspace Host contract pass to root build, typecheck, lint, and documentation typecheck. For opted-in Host packages, that pass emits both local reflection and strict Host-for-Client `/remote` contracts before consumers resolve them. Generated local declarations keep `TYPERT` typed as `unknown`, so business packages do not depend on the registry.
|
||||
|
||||
At build time, `CordisCatalogProjector` consumes the analyzed `FaceModel` and `TypeGraph` once to generate `docs/cordis-catalog/events.md`, `docs/cordis-catalog/services.md`, and the static `SERVICE_API`, `EVENT_API`, and `TYPE_API` catalog committed for `tool-cordis`. `tool-cordis` reads that static catalog and has no runtime dependency on `ctx.typert`. [`dsh-typert-loader`](../../../../packages/typert/loader/README.md) and the registry remain an independent runtime path: the loader follows Cordis Loader entry lifecycle events, imports an explicitly published `./typert` host artifact, and registers it through `ctx.typert`; neither component supplies the current `cordis_inspect` catalog.
|
||||
|
||||
@@ -50,4 +50,4 @@ For each supported node kind and literal category, Zod emitter tests run both su
|
||||
|
||||
New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities.
|
||||
|
||||
Explicit opt-in keeps artifact publication and package exports under package ownership, while ordinary root builds and typechecks incur no whole-workspace Typert generation phase. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after importing them directly.
|
||||
Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after importing them directly.
|
||||
|
||||
@@ -22,7 +22,7 @@ PackageModel 识别 Cordis service、event、`@typert object` 引用对象和 `@
|
||||
|
||||
[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) 提供 `ctx.typert`,且只负责运行时注册:一个 contribution 原子携带 package-face reflection 与可选 Zod schema,并随 Cordis effect 撤销。注册表不分析 TypeScript,也不合并两个 face。JSON Schema 是对已注册 Zod schema 的按需投影。
|
||||
|
||||
包产物发布采用显式 opt-in。`WorkspaceTypertGenerator` 仅在被调用时校验所请求 face 的根目录产物协议:host face 必须通过面向用户的 subpath `package/typert` 暴露 `package/lib/typert.host.{js,d.ts}`,client face 必须通过 `package/client/typert` 暴露 `package/lib/typert.client.{js,d.ts}`。它既不修改 exports,也不作为根目录普通 build 或 typecheck 的一部分运行,因此这些命令不会生成全仓 Typert 产物。生成的声明将 `TYPERT` 类型保持为 `unknown`,因此业务包不依赖注册表。
|
||||
包产物发布仍通过 package exports 采用显式 opt-in。`WorkspaceTypertGenerator` 仅在被调用时校验所请求 face 的根目录产物协议:host face 必须通过面向用户的 subpath `package/typert` 暴露 `package/lib/typert.host.{js,d.ts}`,client face 必须通过 `package/client/typert` 暴露 `package/lib/typert.client.{js,d.ts}`;它不会修改这些 exports。后续的 [TypeRT Remote 设计](2026-08-02-typert-remote-method-calls.md) 为根目录 build、typecheck、lint 与文档类型检查增加了全仓 Host 契约 pass。对于已 opt-in 的 Host 包,该 pass 会在消费方解析两者之前生成本地反射产物与严格的 Host-for-Client `/remote` 契约。生成的本地声明将 `TYPERT` 类型保持为 `unknown`,因此业务包不依赖注册表。
|
||||
|
||||
构建期的 `CordisCatalogProjector` 一次消费分析后的 `FaceModel` 与 `TypeGraph`,生成 `docs/cordis-catalog/events.md`、`docs/cordis-catalog/services.md`,以及为 `tool-cordis` 提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` catalog。`tool-cordis` 读取该静态 catalog,运行时不依赖 `ctx.typert`。[`dsh-typert-loader`](../../../../packages/typert/loader/README.md) 与注册表仍是独立的运行时路径:loader 监听 Cordis Loader 配置项生命周期事件,导入显式发布的 `./typert` host 产物,并通过 `ctx.typert` 注册;两者都不是当前 `cordis_inspect` catalog 的数据源。
|
||||
|
||||
@@ -50,4 +50,4 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars
|
||||
|
||||
新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。
|
||||
|
||||
显式 opt-in 使产物发布与 package exports 由各包自行管理,根目录普通 build 和 typecheck 不会引入全仓 Typert 生成阶段。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方直接导入后仍持有的 Zod 实例,卸载流程无法控制。
|
||||
包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 契约 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方直接导入后仍持有的 Zod 实例,卸载流程无法控制。
|
||||
|
||||
@@ -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-07-29-dsh-source-launch-tsx-esm.md
|
||||
2026-07-29-dsh-source-launch-tsx-esm.md: 21e912c7c7bbdd70142c202105d9a3035442884a
|
||||
2026-07-29-dsh-source-launch-tsx-esm.zh.md: dc6150b7017777eea99778cc9813e17402354462
|
||||
2026-07-29-dsh-source-launch-tsx-esm.md: aabe6c2d6a92b5cc6568eee1d42df3a6998cf0ca
|
||||
2026-07-29-dsh-source-launch-tsx-esm.zh.md: fdeb8a976e02bb9311a6ec9d097d09151a998a04
|
||||
|
||||
@@ -35,4 +35,4 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`
|
||||
- One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line.
|
||||
- TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode.
|
||||
- The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures.
|
||||
- Startup improves ~0.4s over the full tsx default (`demo:headless` and ACP keep `--import tsx`; their graphs were not audited for CJS-hook dependence and their launch latency is not on the interactive path).
|
||||
- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh run` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path).
|
||||
|
||||
@@ -35,4 +35,4 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`
|
||||
- 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。
|
||||
- TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。
|
||||
- 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。
|
||||
- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 与 ACP 保持 `--import tsx`:其依赖图未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。
|
||||
- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh run` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。
|
||||
|
||||
@@ -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-02-typert-remote-method-calls.md
|
||||
2026-08-02-typert-remote-method-calls.md: 215c647bcd7413b92625ee670022dc7316e3045a
|
||||
2026-08-02-typert-remote-method-calls.zh.md: 0ce431b7cbc948e937f722f2769b15a1d26dcec9
|
||||
2026-08-02-typert-remote-method-calls.md: f3db8b9eec5eb8fb610fc58edad9313e4f715326
|
||||
2026-08-02-typert-remote-method-calls.zh.md: da4459432e8c9e08821bcded2921cbd33fc5e8c8
|
||||
|
||||
@@ -217,6 +217,8 @@ Host lib build
|
||||
|
||||
The existing top-level `build` still runs `build:lib` before `build:web`, but `build:lib` must complete the Host and Remote artifacts before starting Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build.
|
||||
|
||||
Compiler-backed repository gates that resolve the consumer surface have the same prerequisite even when their primary inputs are source files. The public `typecheck`, `lint`, and `doc-typecheck` commands run the Host contract pass first. The gate scheduler may use their `*:contracts-ready` variants only after an explicit TypeRT-contract or complete-build dependency, so parallel lanes neither read missing declarations nor run concurrent generators against the same outputs.
|
||||
|
||||
## The `/remote` package entry
|
||||
|
||||
Every business package that provides Remote methods exports a generated `/remote` subpath:
|
||||
@@ -490,6 +492,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h
|
||||
|
||||
- Goal Service directly decorates mutation methods whose business signatures already match the Remote contract and keeps `remoteExportCreate(...)` only to adapt `GoalView` into `CreateGoalResult`, without a second route, codec, or Client method list.
|
||||
- A clean `build:lib` emits Host and consumer Remote artifacts before Client compilation, including the business package's JS, DTS, and declaration map under `/remote`.
|
||||
- After `clean`, standalone `typecheck`, `lint`, and `doc-typecheck` regenerate the Remote contracts; the pre-push hook uses the same prepared typecheck, and CI source consumers wait for one shared contract pass.
|
||||
- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `ctx.remote.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace.
|
||||
- Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub.
|
||||
- Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope.
|
||||
@@ -501,7 +504,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h
|
||||
|
||||
## Consequences
|
||||
|
||||
Remote API types depend on generated `lib` declarations. Build orchestration must finish the Host contract pass before compiling Host and Client consumers; an incorrect order makes a clean build depend on stale artifacts.
|
||||
Remote API types depend on generated `lib` declarations. Build and gate orchestration must finish the Host contract pass before compiling or semantically analyzing Host and Client consumers; an incorrect order makes a clean command depend on stale artifacts.
|
||||
|
||||
Source navigation requires a Remote package to publish both its declaration map and the `src` file referenced by the map. If package `files` omits either side, types still compile but consumer navigation stops at the generated DTS. The workspace manifest check must therefore treat both as one publication contract.
|
||||
|
||||
|
||||
@@ -217,6 +217,8 @@ Host lib build
|
||||
|
||||
现有顶层 `build` 仍表现为先 `build:lib`、再 `build:web`,但 `build:lib` 内部必须先完成 Host 与 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。
|
||||
|
||||
即使主要输入是源文件,需要通过编译器解析消费方 surface 的仓库门禁也有相同的前置条件。公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先执行 Host 契约 pass。门禁调度器仅可在显式的 TypeRT 契约依赖或完整构建依赖完成后使用对应的 `*:contracts-ready` 变体,使并行 lane 既不会读取缺失的声明,也不会针对同一输出并发运行多个生成器。
|
||||
|
||||
## `/remote` 包入口
|
||||
|
||||
每个提供 Remote 方法的业务包导出生成的 `/remote` 子路径:
|
||||
@@ -490,6 +492,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS
|
||||
|
||||
- Goal Service 直接装饰业务签名已经符合 Remote 契约的变更类方法,仅保留 `remoteExportCreate(...)` 把 `GoalView` 适配为 `CreateGoalResult`,无需第二条路由、第二份 codec 或 Client 方法清单。
|
||||
- 一次干净的 `build:lib` 会在 Client 编译前生成 Host 与消费方 Remote 产物,包括业务包 `/remote` 下的 JS、DTS 和 declaration map。
|
||||
- `clean` 后,单独运行 `typecheck`、`lint` 或 `doc-typecheck` 都会重新生成 Remote 契约;pre-push 钩子使用同一个已包含契约准备步骤的 typecheck,CI 中的源码消费方则等待一次共享的契约 pass。
|
||||
- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `ctx.remote.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。
|
||||
- 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。
|
||||
- Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。
|
||||
@@ -501,7 +504,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS
|
||||
|
||||
## 后果
|
||||
|
||||
Remote API 类型依赖生成的 `lib` 声明,构建编排必须在 Host 和 Client 消费端编译前完成 contract pass;顺序错误会让干净构建依赖陈旧产物。
|
||||
Remote API 类型依赖生成的 `lib` 声明,构建与门禁编排必须在对 Host 和 Client 消费方进行编译或语义分析之前完成 Host 契约 pass;顺序错误会使干净环境中的命令依赖陈旧产物。
|
||||
|
||||
源码导航依赖 Remote package 同时发布 declaration map 和 map 指向的 `src`。package `files` 漏掉任一侧时类型仍可编译,但消费端跳转会停在生成 DTS,因此 workspace manifest 校验必须把两者作为同一发布契约。
|
||||
|
||||
|
||||
@@ -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-03-pi-ai-declared-provider-catalog.md
|
||||
2026-08-03-pi-ai-declared-provider-catalog.md: d75b6bdb91d60026636bf320f8c6625590849a41
|
||||
2026-08-03-pi-ai-declared-provider-catalog.zh.md: f8dba9900b1a7a3abcb16c70a35cc18f0c44219f
|
||||
2026-08-03-pi-ai-declared-provider-catalog.md: f908eb6293b77680193fcd8f7be7a9089477855a
|
||||
2026-08-03-pi-ai-declared-provider-catalog.zh.md: ce91abd6dc71f790c72766cd3f819096d596182c
|
||||
|
||||
@@ -14,7 +14,7 @@ The adapter also streamed through `streamSimple` from `@earendil-works/pi-ai/com
|
||||
|
||||
A provider route is a **declaration**, and the installed catalog is its default. `resolveProfiles` no longer checks route keys against `getBuiltinProviders()`. Instead each route resolves to a materialized model list plus the pi-ai `Provider` that serves it:
|
||||
|
||||
- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, `maxTokens`. Pricing and input modalities are absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Reasoning is absent for a different reason: a bare capability flag would make pi-ai advertise effort levels with no `thinkingLevelMap` to spell them, so it rides the installed entry or is absent. Materialization spreads the installed entry and overrides those four fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already.
|
||||
- `catalog.ts` merges the installed catalog under the profile's own entries. A profile's `models` list *replaces* the route's catalog (an absent or empty list serves it unchanged), and each entry defaults its unset fields from the installed model of the same `id`. Only the fields the harness consumes are configurable — at this note's writing `id`, `name`, `contextWindow`, `maxTokens`; [[2026-08-08-pi-ai-per-model-reasoning-declarations]] later added `reasoningEfforts` and `compat`, which is also where the original "reasoning rides the installed entry or is absent" stance was revisited (a bare capability flag stays rejected; a full per-level declaration with wire spellings does not have its problem). Pricing and input modalities remain absent from the surface because nothing reads them: `replay.ts` zeroes pi-ai's cost metadata and `context.ts` keeps only text blocks. Materialization spreads the installed entry and overrides the configured fields, rather than enumerating the result: an enumerated rebuild silently drops every `Model` field this package does not model, which is how `headers` went missing from an nvidia route once already.
|
||||
- `provider.ts` builds the route's `Provider`. A catalog route that keeps its catalog protocol **reuses** the installed provider with `getModels()` replaced; every other route is built by `createProvider()` over a protocol table whose entries are the same `@earendil-works/pi-ai/api/*.lazy` factories pi-ai's own provider factories use. That table is narrower than pi-ai's full API set on purpose — it holds only protocols a profile can completely describe with a key, an endpoint, and headers, so Bedrock (SigV4 plus a region), Vertex (project, location, ADC), Azure (provider environment plus an api-version), and Codex (OAuth) are absent rather than offered as routes that cannot authenticate. Catalog routes still reach them through their own provider; only an explicit override is refused.
|
||||
- `adapter.ts` turns each resolution into an **immutable snapshot** — the profiles plus a `createModels()` collection holding those providers — and every operation captures a whole snapshot before its first `await`.
|
||||
- A model's **explicitly configured** `maxTokens` becomes the seam's `defaultMaxTokens`. The value inherited from the installed catalog does not: pi-ai requires `Model.maxTokens` as the model's output *capability*, while `defaultMaxTokens` is a cap the deployment chose to send on requests that name none, and materializing the former as the latter would start capping every request at a number nobody picked.
|
||||
@@ -35,7 +35,7 @@ The configurable-provider directory is now the installed catalog **joined with**
|
||||
|
||||
pi-ai reports a model with no reasoning metadata as supporting the single level `off`, and the adapter used to pass that straight through. It reaches the seam as a one-item effort list, which every surface renders as a picker holding one selectable control — and that control is a lie: `off` becomes an *omitted* reasoning option at dispatch, byte-for-byte the request that naming no effort already produces. A provider whose own default is to think keeps thinking while the surface shows `off` selected.
|
||||
|
||||
`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives.
|
||||
`reasoningInfo` therefore omits the seam's `reasoning` field whenever `model.reasoning` is falsy. The condition is the model's own metadata, not where the model came from, so this covers every hand-declared model whose entry declares no `reasoningEfforts` ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] made declared efforts carry that metadata) **and** the 251 installed-catalog models pi-ai marks as non-reasoning. Those previously offered the lone `off`; they now offer nothing, and the surface shows the provider default alone. Models that do carry reasoning metadata are untouched — their level list still crosses the seam unfiltered, `off` included, because there it selects between real alternatives.
|
||||
|
||||
### Credentials stay outside pi-ai
|
||||
|
||||
@@ -50,7 +50,7 @@ A route's auth follows from that. A catalog route keeps the installed provider's
|
||||
- **Keep `createProvider()` but skip the `Models` collection**, streaming through `provider.streamSimple(model, ctx, {apiKey})`. Smallest diff and the credential path is untouched, but `createProvider`'s `auth` is a required field that this path never invokes — a required-by-signature implementation with no caller. It also leaves `refreshModels` needing a hand-built `RefreshModelsContext`, and keeps the adapter off the runtime pi-ai actually supports.
|
||||
- **Reuse the installed provider for catalog routes and `createProvider()` only for declared ones**, with no shared resolution. Zero risk to catalog behavior, but catalog materialization, endpoint override, and per-model configuration would each exist twice, and a catalog route that repoints its protocol would have to jump paths mid-resolution. The chosen split confines the asymmetry to provider construction, where it is forced by pi-ai not exposing a built provider's API implementations.
|
||||
- **Rebuild every route through `createProvider()`**, including catalog ones. Fully symmetric, but a built `Provider` does not expose its `api`, so the protocol table would become the ceiling on which providers work — Bedrock loads its Smithy module through a separate entry point and would silently stop working.
|
||||
- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer reads those fields, so a configured price or modality would change nothing while reading as supported.
|
||||
- **Expose pi-ai's whole `Model` shape** (cost, input modalities, `thinkingLevelMap`, `compat`). Maximum configurability, but no current consumer read those fields then, so a configured price or modality would change nothing while reading as supported. The consumer-driven half of this arrived later: [[2026-08-08-pi-ai-per-model-reasoning-declarations]] opened reasoning (as `reasoningEfforts`, not a raw `thinkingLevelMap`) and the two reasoning-dispatch `compat` switches once selectors and dispatch actually consumed them; cost and modalities stay closed for the original reason.
|
||||
|
||||
- **Keep one mutable `Models` collection and re-sync it.** Fewer allocations, and correct for every operation that resolves synchronously. It is exactly wrong for the one that does not: `stream()` awaits a credential between capturing its model and dispatching it.
|
||||
- **Simulate an atomic directory swap with dispose-then-register.** No seam change, and it works whenever the new set is valid — which is the case that never needed atomicity.
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
提供方路由是一份**声明**,已安装 catalog 是它的默认值。`resolveProfiles` 不再拿路由键去核对 `getBuiltinProviders()`,而是把每条路由解析成一份物化模型列表,外加服务它的 pi-ai `Provider`:
|
||||
|
||||
- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow`、`maxTokens`。定价与输入模态不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。推理缺席则是另一个理由:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,因此它沿用已安装条目或直接缺席。物化时以已安装条目铺底、再覆盖那四个字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。
|
||||
- `catalog.ts` 把已安装 catalog 合并到 profile 自身条目之下。profile 的 `models` 列表*替换*该路由的 catalog(列表缺席或为空则原样服务),每个条目从同 `id` 的已安装模型继承自身未设置的字段。只有 harness 会消费的字段可配置——本 note 写就时为 `id`、`name`、`contextWindow`、`maxTokens`;[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 之后加入了 `reasoningEfforts` 与 `compat`,当初「推理沿用已安装条目或直接缺席」的立场也在那里被重新审视(孤立的能力布尔量仍被拒绝;带 wire 拼写的逐档位完整声明没有它那个问题)。定价与输入模态仍不出现在配置面,因为没有任何读取方:`replay.ts` 把 pi-ai 的成本元数据清零,`context.ts` 只保留文本块。物化时以已安装条目铺底、再覆盖已配置的字段,而不是逐字段枚举结果:枚举式重建会静默丢弃本包未建模的每一个 `Model` 字段——`headers` 就是这样从某条 nvidia 路由上消失过一次。
|
||||
- `provider.ts` 构造路由的 `Provider`。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换 `getModels()`;其余路由都由 `createProvider()` 基于一张协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的 `@earendil-works/pi-ai/api/*.lazy` factory。该表刻意窄于 pi-ai 的完整 API 集合——只保留 profile 能用密钥、端点与标头完整描述的协议,因此 Bedrock(SigV4 加 region)、Vertex(project、location、ADC)、Azure(提供方环境加 api-version)与 Codex(OAuth)不在其中,而不是被当作无法认证的路由提供出去。catalog 路由仍可经自己的 provider 抵达它们;被拒的只有显式覆盖。
|
||||
- `adapter.ts` 把每次解析变成一份**不可变快照**——profiles 加上持有这些 provider 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份。
|
||||
- 模型**显式配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`;从已安装 catalog 继承来的那份不会:pi-ai 要求 `Model.maxTokens` 表示模型的输出**能力**,而 `defaultMaxTokens` 是部署选定、发给未点名上限的请求的那个值,把前者物化成后者会让每个请求都被一个无人选择的数字封顶。
|
||||
@@ -35,7 +35,7 @@ Status: implemented
|
||||
|
||||
pi-ai 把没有推理元数据的模型报告为只支持 `off` 一档,而适配器此前原样透传。它抵达 seam 时是一个单元素的 effort 列表,任何界面都会把它渲染成一个只有一项可选控件的选择器——而这个控件在撒谎:`off` 在派发时变成被*省略*的 reasoning 选项,与「不点名任何档位」产出的请求逐字节相同。自身默认就在思考的提供方会继续思考,界面却显示 `off` 已选中。
|
||||
|
||||
因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖每一个手工声明的模型**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。
|
||||
因此只要 `model.reasoning` 为假,`reasoningInfo` 就省略 seam 的 `reasoning` 字段。判据是模型自身的元数据,而非模型的来源,所以它覆盖条目未声明 `reasoningEfforts` 的每一个手工声明模型([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 让声明的档位携带这份元数据)**以及** pi-ai 标记为不具备推理能力的那 251 个已安装 catalog 模型。它们此前提供那个孤零零的 `off`,现在什么也不提供,界面只剩提供方默认。携带推理元数据的模型不受影响——其档位列表仍不经筛选地穿过 seam、`off` 也在内,因为在那里它是在真实备选之间做选择。
|
||||
|
||||
### 凭据留在 pi-ai 之外
|
||||
|
||||
@@ -50,7 +50,7 @@ pi-ai 的 `Models` 自带一套凭据概念——按提供方 id 索引的 `Cred
|
||||
- **保留 `createProvider()` 但不建 `Models` 集合**,改由 `provider.streamSimple(model, ctx, {apiKey})` 发起。改动最小且凭据路径原封不动,但 `createProvider` 的 `auth` 是必填字段,这条路上它永远不会被调用——一份因签名而必填、却没有调用方的实现。它还让 `refreshModels` 需要手工构造 `RefreshModelsContext`,并使适配器始终不在 pi-ai 真正支持的运行时上。
|
||||
- **catalog 路由复用已安装提供方,只有声明式路由走 `createProvider()`**,且两者不共享解析。对 catalog 行为零风险,但 catalog 物化、端点覆盖与每模型配置这三件事都要各写两遍,而改指协议的 catalog 路由还得在解析中途跳到另一条路径。已采纳的拆法把不对称收敛在提供方构造这一处——那里的不对称是 pi-ai 不暴露已构造提供方的 API 实现所强加的。
|
||||
- **让每条路由都经 `createProvider()` 重建**,包括 catalog 路由。完全对称,但已构造的 `Provider` 不暴露自己的 `api`,于是协议表会成为「哪些提供方能用」的天花板——Bedrock 经独立入口加载其 Smithy 模块,会因此静默失效。
|
||||
- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当前没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。
|
||||
- **完整暴露 pi-ai 的 `Model` 形状**(成本、输入模态、`thinkingLevelMap`、`compat`)。可配置性最大,但这些字段当时没有任何读取方,因此配了价格或模态什么也不会改变,却看起来像是受支持的。这条否决里由消费方驱动的那一半后来兑现了:[[2026-08-08-pi-ai-per-model-reasoning-declarations]] 在选择器与分派真正消费之后开放了推理(以 `reasoningEfforts` 的形态,而非裸 `thinkingLevelMap`)和两个推理分派 `compat` 开关;成本与模态仍因原有理由保持关闭。
|
||||
|
||||
- **保留单个可变 `Models` 集合并重新同步。** 分配更少,且对每个同步完成解析的操作都是正确的;唯独对那个不同步的操作恰恰是错的:`stream()` 会在捕获模型与派发模型之间 await 一次凭据。
|
||||
- **用「先 dispose 再注册」模拟目录原子替换。** 无需改 seam,且在新集合有效时确实可用——而那正是从不需要原子性的那种情形。
|
||||
|
||||
@@ -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-05-profile-plugin-bundles.md
|
||||
2026-08-05-profile-plugin-bundles.md: 11a8ac3d4005371ca9596ba237aaf42a8e770dee
|
||||
2026-08-05-profile-plugin-bundles.zh.md: 0e9ebf657ccb9d05967d90a935b356acf287a24c
|
||||
2026-08-05-profile-plugin-bundles.md: 8613e600ad633818abb4319e614230340c3b3876
|
||||
2026-08-05-profile-plugin-bundles.zh.md: 03a771364c4f3262801f28e68b96ec5de633e5ec
|
||||
|
||||
@@ -12,7 +12,9 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y
|
||||
|
||||
Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`.
|
||||
|
||||
The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh --profile headless "task"` replaces `-p`; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency).
|
||||
The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh run [--profile <name>] "task"` owns one-shot execution and defaults to the headless profile, while generic `dsh --profile <name>` boots without a task; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency).
|
||||
|
||||
The [`dsh run` command decision](../feature/2026-08-08-dsh-run-headless-command.md) owns the one-shot grammar; this note owns the profile composition it selects.
|
||||
|
||||
Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ Status: implemented
|
||||
|
||||
一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。
|
||||
|
||||
已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh --profile headless "task"` 取代 `-p`;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。
|
||||
已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile <name>] "task"` 负责一次性执行,默认使用 headless profile,而通用的 `dsh --profile <name>` 只启动 profile,不携带任务;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。
|
||||
|
||||
[`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。
|
||||
|
||||
解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。
|
||||
|
||||
|
||||
@@ -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-08-bounded-session-persistence-write-batching.md
|
||||
2026-08-08-bounded-session-persistence-write-batching.md: 46dc612492fa1bfa805f77f865f14b168f52776f
|
||||
2026-08-08-bounded-session-persistence-write-batching.zh.md: 4aafdd652aad87cfe74a449428cdbbf13312d260
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note: Bounded session persistence write batching
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-08-bounded-session-persistence-write-batching.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Streaming responses can emit many `assistant/chunk` events in a short interval. The persistence coordinator previously scheduled a backend append as soon as an idle queue received one event. Events arriving while that append was active shared a follow-up batch, but a fast backend could still produce many small durable appends. Each JSONL append creates and syncs a Zstandard frame or raw suffix, while each SQLite append opens and commits a transaction and increments the session revision.
|
||||
|
||||
Dropping chunk events or replacing them with assembled messages would reduce logical storage, but it would also change the event log, replay, sequence numbers, timestamps, and provenance. The write-amplification problem does not require that larger semantic change.
|
||||
|
||||
### Quantified baseline
|
||||
|
||||
Repository fixtures make the logical volume concrete. Decoding the current packed rows in [`goal-multi-turn-actions`](../../../../apps/web/tests/snapshots/goal-multi-turn-actions/session.jsonl) yields 2,098 events: 2,017 chunks (96.1%). Their unpacked JSONL lines occupy 332,647 of 379,225 event bytes (87.7%), while chunk packing reduces the committed file to 89,176 bytes and 182 storage rows, including 23 packed chunk rows. [`permission-policy-context`](../../../../apps/web/tests/snapshots/permission-policy-context/session.jsonl) yields 813 events: 746 chunks (91.8%) and 118,935 of 184,821 unpacked event bytes (64.4%); its packed file is 84,917 bytes and 123 storage rows, including 14 packed rows. These are tracked deterministic fixtures, not a production workload distribution, but they demonstrate why deleting chunks would reduce logical volume and why the existing packed-row layout already removes much of their JSON envelope cost.
|
||||
|
||||
SQLite stores one row per logical event, so those same logical logs would retain 2,098 and 813 event rows respectively; batching does not change those counts. JSONL writes one Zstandard frame and fsync per durable append batch, while SQLite performs one transaction and one session-revision increment per batch. Runtime files do not record former append boundaries, so fixture row counts cannot honestly be presented as fsync or transaction counts.
|
||||
|
||||
The scheduling bound is deterministic. With an immediately resolving sink, the former immediate controller could issue one append for each event arriving after the previous append completed. A controller test admits 20 events 10 ms apart: the 200 ms fixed window hands all 20 to one append. This is a 20-to-1 reduction for that cadence, not a universal ratio. Sparse events, mandatory flushes, slow prior writes, and different arrival rates produce different batch sizes.
|
||||
|
||||
## Decision
|
||||
|
||||
The first-party JSONL and SQLite plugins expose `writeBatchMaxDelayMs`, a positive integer no greater than Node's timer limit. Its default is `200`. Each plugin resolves the value at load and passes it to `PersistenceCoordinator`; the coordinator remains the single owner of batching behavior.
|
||||
|
||||
Each live Session receives a package-private `SessionWriteBehind`. When its pending queue changes from empty to non-empty, the controller starts one fixed window. Later events join that batch without resetting the deadline: this is bounded coalescing, not debounce. When the deadline expires, the controller hands the complete pending prefix to the existing per-id serialization and `appendBatch` path. At most one write for a Session is active. Events admitted during that write form a new pending prefix with their own fixed deadline; if that deadline expires before the active write completes, the new prefix starts immediately after it.
|
||||
|
||||
`writeBatchMaxDelayMs` bounds only the controller's intentional batching wait. Event-loop scheduling, initialization, an earlier serialized operation, and backend I/O can delay durable completion, so the option is not a hard fsync or crash-loss SLA.
|
||||
|
||||
`session/flush` cancels any remaining wait and becomes a shared quiescence barrier. It drains the active attempt and every event admitted while the barrier is running before it resolves. Session retirement and backend disposal use that same barrier, so lifecycle teardown never waits for the batching timer. The checkpoint policy continues to place mandatory barriers before model requests and top-level tool side effects.
|
||||
|
||||
Every event remains durable in its original order and shape. The controller copies each event on admission; no `assistant/chunk`, `seq`, `time`, surface metadata, or storage record is removed or rewritten. JSONL can therefore encode more events in one append frame, and SQLite can insert more event rows in one transaction, without changing either on-disk format or schema version.
|
||||
|
||||
A failed background append restores its complete batch before any newer pending events, reports the failure once, and pauses automatic retry. The next newly admitted event opens a fresh fixed window; an explicit flush, retirement, or disposal retries immediately and surfaces a repeated failure to its caller. This avoids a timer-driven failure loop while preserving the existing recoverable flush boundary.
|
||||
|
||||
This decision supersedes only the immediate scheduling cadence in [Collapse live persistence into one flush controller](../simplification/2026-07-23-collapse-persistence-flush-state.md). That note remains authoritative for one controller per live Session, retained failed batches, per-id serialization, retirement, and quiescent disposal. The [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) remains the owner of the backend hook boundary.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Do not persist streaming chunk events.** Rejected here: it changes the event-sourced authority and recovery semantics rather than only physical write cadence. The existing [assembled-message rejection](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md) remains the guardrail until a no-information-loss replacement defines replay, fork, provenance, sequence, and crash behavior independently. The [packed-row decision](2026-07-26-packed-chunk-rows-by-default.md) remains the complementary JSONL storage-size optimization.
|
||||
|
||||
**Write only at semantic checkpoints.** Rejected: it maximizes batching but makes the ordinary crash-loss window depend on a separately mounted policy. Bounded background writes preserve progress between checkpoints while mandatory flushes keep their stronger ordering contract.
|
||||
|
||||
**Debounce from the latest event.** Rejected: a continuously streaming response could postpone its first write indefinitely. A fixed window from the first pending event provides a real upper bound on intentional coalescing wait.
|
||||
|
||||
**Implement timers separately in JSONL and SQLite.** Rejected: scheduling, failure retention, flush races, and teardown are backend-neutral lifecycle concerns. Duplicating them would reopen the drift that `PersistenceCoordinator` removed.
|
||||
|
||||
## Verification
|
||||
|
||||
The controller tests use a fake clock to prove the fixed, non-resetting 200 ms window; immediate and shared flush barriers; events admitted during a barrier; an over-budget tail behind an active write; ordered failure retention; paused automatic retry; and explicit retry of an overlapping background failure. Coordinator tests run the controller through Session notifications, retirement, collision reclamation, and teardown. The JSONL and SQLite suites retain their storage-format, transaction, recovery, and shared persistence-contract coverage.
|
||||
|
||||
## Consequences
|
||||
|
||||
High-frequency event bursts normally produce fewer durable append operations while preserving the exact logical event count. The reduction depends on arrival rate and backend latency: a burst inside one 200 ms window becomes one batch, while mandatory flushes and sparse events can still produce small batches.
|
||||
|
||||
This decision does not cap pending event count or bytes behind a slow backend, and it does not reduce SQLite rows or the decoded logical log. A demonstrated memory bound or logical-retention policy would require its own failure and replay contract rather than another hidden timer rule.
|
||||
|
||||
An admitted event can remain only in memory during the configured window, and then while scheduling or backend work is outstanding. Deployments choose a smaller value for a narrower ordinary loss window or a larger value for stronger batching. Explicit durability boundaries remain unchanged and bypass the wait.
|
||||
|
||||
The new deep module gives the timer, active write, pending prefix, retry pause, and barrier one owner. `PersistenceCoordinator` retains initialization and identity serialization; backends retain only durable storage primitives. Neither `SESSION_FORMAT_VERSION` nor SQLite `SCHEMA_VERSION` changes.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note: 为会话持久化写入批处理设定上界
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-08-bounded-session-persistence-write-batching.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
流式响应可能会在短时间内发出大量 `assistant/chunk` 事件。此前,只要空闲队列收到一个事件,持久化协调器就会立即调度一次后端追加。该追加仍在进行时到达的事件会共用一个后续批次,但如果后端速度很快,仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀,而每次 SQLite 追加都会打开并提交一个事务,同时递增会话修订版本。
|
||||
|
||||
丢弃分片事件或用组装后的消息替代它们可以减少逻辑存储量,但也会改变事件日志、回放、序列号、时间戳和来源信息。写放大问题不要求采取这项语义变化更大的方案。
|
||||
|
||||
### 量化基线
|
||||
|
||||
仓库 fixture(测试前置数据)让逻辑数据量有了具体依据。对当前 [`goal-multi-turn-actions`](../../../../apps/web/tests/snapshots/goal-multi-turn-actions/session.jsonl) 中的打包行进行解码,可得到 2,098 个事件,其中 2,017 个是分片(96.1%)。这些分片解包后的 JSONL 行共 332,647 字节,占全部事件 379,225 字节的 87.7%;分片打包则把仓库中的已提交文件缩小到 89,176 字节和 182 个存储行,其中包括 23 个打包分片行。[`permission-policy-context`](../../../../apps/web/tests/snapshots/permission-policy-context/session.jsonl) 可得到 813 个事件,其中 746 个是分片(91.8%);这些分片解包后的 JSONL 行共 118,935 字节,占全部事件 184,821 字节的 64.4%。其打包文件为 84,917 字节,共 123 个存储行,其中包括 14 个打包行。这些是纳入版本控制的确定性 fixture,不代表生产工作负载分布;但它们说明了删除分片为何会降低逻辑数据量,也说明现有打包行布局已经消除了大量 JSON 包装开销。
|
||||
|
||||
SQLite 每个逻辑事件存储一行,因此同样的逻辑日志会分别保留 2,098 和 813 个事件行;批处理不会改变这些数量。JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fsync,SQLite 每个批次会执行一次事务并递增一次会话修订版本。运行时文件不记录原有追加边界,因此不能把 fixture 的存储行数当作 fsync 或事务次数。
|
||||
|
||||
调度上界是确定的。当写入端会立即完成每次操作时,原来的即时控制器可能对每个在前一次追加完成后到达的事件分别发起一次追加。一个控制器测试以 10 ms 的间隔接纳 20 个事件:200 ms 固定窗口会把全部 20 个事件交给一次追加。对于这种到达节奏,追加次数从 20 次降至 1 次,但这不是普遍比例。稀疏事件、强制 flush、较慢的前序写入和不同到达速率都会产生不同的批次大小。
|
||||
|
||||
## 决策
|
||||
|
||||
第一方 JSONL 与 SQLite 插件公开 `writeBatchMaxDelayMs`,其值必须是一个不超过 Node 计时器上限的正整数,默认值为 `200`。每个插件都会在加载时解析该值,再传给 `PersistenceCoordinator`;批处理行为仍只由协调器负责。
|
||||
|
||||
每个活跃的 Session 都有一个包私有 `SessionWriteBehind`。当其待处理队列从空变为非空时,控制器会启动一个固定窗口。后续事件加入该批次但不会重置截止时间:这属于有界合并,而不是防抖。截止时间到达后,控制器会把完整的待处理前缀交给现有的按 id 串行化机制,并沿 `appendBatch` 路径写入。同一 Session 同时最多有一个活跃写入。该写入期间接纳的事件会形成新的待处理前缀,并拥有自己的固定截止时间;如果该截止时间在活跃写入完成前到期,新前缀会在前一次写入完成后立即开始写入。
|
||||
|
||||
`writeBatchMaxDelayMs` 只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间,因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。
|
||||
|
||||
`session/flush` 会取消剩余等待,并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试,并排空屏障运行期间接纳的每个事件。Session 退役与后端 dispose(资源释放)共用该屏障,因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。
|
||||
|
||||
每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件;任何 `assistant/chunk`、`seq`、`time`、surface 元数据或存储记录都不会被删除或重写。因此,JSONL 可以在一个追加帧中编码更多事件,SQLite 可以在一个事务中插入更多事件行,而无需改变任一种磁盘格式或 schema 版本。
|
||||
|
||||
后台追加失败后,控制器会把完整批次恢复到所有较新的待处理事件之前,报告一次该失败,并暂停自动重试。随后新接纳的第一个事件会开启新的固定窗口;显式 flush、退役或 dispose 会立即重试,如果故障再次发生,则会向调用方暴露该故障。这可以避免计时器驱动的失败循环,同时保留现有可恢复的 flush 边界。
|
||||
|
||||
本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.md)中的即时调度节奏。对于每个活跃 Session 使用一个控制器、保留失败批次、按 id 串行化、退役和完全停稳的 dispose,原 Agent Note 仍是权威记录。后端钩子边界仍由[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.md)定义。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**不持久化流式分片事件。** 这里不采纳:这会改变事件日志作为真源的地位及恢复语义,而不只是改变物理写入节奏。在无信息损失的替代方案独立定义回放、fork、来源信息、序列和崩溃行为之前,现有的[拒绝仅保留组装消息的决策](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)仍是防护规则。[打包行决策](2026-07-26-packed-chunk-rows-by-default.md)仍是配套的 JSONL 存储体积优化。
|
||||
|
||||
**仅在语义检查点写入。** 不采纳:此方案会最大化批处理,却让普通的崩溃丢失窗口取决于另行挂载的策略。有界后台写入会在检查点之间持久化进度,而强制 flush 继续提供更强的顺序契约。
|
||||
|
||||
**按最新事件重置防抖窗口。** 不采纳:持续不断的流式响应可能无限期推迟首次写入。由第一个待处理事件启动的固定窗口,为主动合并等待提供了真正的上界。
|
||||
|
||||
**分别在 JSONL 与 SQLite 中实现计时器。** 不采纳:调度、失败保留、flush 竞态和 teardown 都是后端无关的生命周期问题。重复实现这些机制会重新引入 `PersistenceCoordinator` 已消除的实现漂移。
|
||||
|
||||
## 验证
|
||||
|
||||
控制器测试使用假时钟证明固定且不会重置的 200 ms 窗口、即时且可共享的 flush 屏障、屏障运行期间接纳的事件、在活跃写入之后已超过窗口时限的尾部批次、有序保留失败批次、暂停自动重试,以及显式 flush 会重试与其重叠发生的后台失败。协调器测试会在 Session 通知、退役、冲突回收和 teardown 路径中验证该控制器。JSONL 与 SQLite 测试套件继续覆盖存储格式、事务、恢复和共享持久化契约。
|
||||
|
||||
## 后果
|
||||
|
||||
高频事件突发通常会减少持久化追加操作,同时保持逻辑事件数量完全不变。减少幅度取决于事件到达速率和后端延迟:位于同一 200 ms 窗口内的突发事件会成为一个批次,而强制 flush 与稀疏事件仍可能产生小批次。
|
||||
|
||||
本决策不会限制因后端缓慢而积压的待处理事件数量或字节数,也不会减少 SQLite 行数或解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略,就必须为其另行定义失败与回放契约,而不是再引入一条隐式计时器规则。
|
||||
|
||||
接纳后的事件在配置窗口内可能只存在于内存中,此后在等待调度或后端工作完成期间也可能如此。部署可以选择较小的值以缩短普通丢失窗口,也可以选择较大的值以加强批处理。显式持久性边界保持不变,并会绕过等待。
|
||||
|
||||
新的 deep module 让计时器、活跃写入、待处理前缀、重试暂停和屏障由一个所有方统一负责。`PersistenceCoordinator` 继续负责初始化和按标识串行化;后端仍只负责持久存储原语。`SESSION_FORMAT_VERSION` 与 SQLite `SCHEMA_VERSION` 均不变。
|
||||
@@ -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-08-client-tool-presentation-ownership.md
|
||||
2026-08-08-client-tool-presentation-ownership.md: 9ea7d7dfc5e18e541550569795c5e1db84d6ad91
|
||||
2026-08-08-client-tool-presentation-ownership.zh.md: 11a42ad69e4374afa6dccd3af74b8bd1b5e5c6f1
|
||||
@@ -0,0 +1,105 @@
|
||||
# Agent Note: Client Tool presentation ownership
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-08-client-tool-presentation-ownership.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Client Runtime already projects Tool calls into a stable lifecycle: it pairs call/result events by `callId`, preserves running and settled forms, and indexes Code Dispatch children by their root call. The chat view nevertheless owned the entire presentation stack. It placed root calls in ChatFlow, composed each root with its subcalls, dispatched every atomic call by Tool name, carried the generic fallback and card models, registered first-party Tool views, and reused those models in the details panel.
|
||||
|
||||
That ownership made `ui-conversation` interpret business Tool names and made subcalls an orphaned concern if an atomic Tool view moved elsewhere. A business package such as `ui-skill` could register a row, but it still depended on conversation's Tool-specific composition contract. Adding Tool-specific Session projection would duplicate a data model the Runtime already owns, while moving only individual React components would leave the composition and model coupling in place.
|
||||
|
||||
## Decision
|
||||
|
||||
Tool is a first-class Client UI concept with one presentation owner, `@deepseek-ai/dsh-client-ui-tool`. Runtime normalizes Code Dispatch into recursive `ToolCallBlock` values: every root or child owns its next level through `subCalls`, and `ConversationSnapshot` exposes no separate parent-to-children map.
|
||||
|
||||
“First-class concept” describes UI ownership only; it adds no Runtime data kind. `ConversationNode` remains the transcript projection, `ChatFlowItem` remains the render unit produced when conversation sorts and groups nodes, `ToolCallBlock` remains the standard data for one call, and `ToolCallTree` only composes root/subcall presentation within Tool. Command continues to render through the separate `'conversation.chat.commandview'` seat and does not become Tool.
|
||||
|
||||
`ui-conversation` owns ordered placement. `deriveChatFlow()` still decides where a settled Tool group appears, and `ChatView` still appends running calls, maintains scroll anchors and selection, and supplies host actions. For each root call it renders the single/session `'conversation.chat.tool'` seat with the root block, selected call id, session cwd, and open-file/inspect callbacks. It does not read Code Dispatch children, branch on Tool names, or import Tool-specific views and card models.
|
||||
|
||||
`ui-tool` occupies that whole-Tool seat. `ToolCallTree` recursively walks the root block's `subCalls` and routes every level through one keyed/session `'tool.call.toolview'` child slot using `entryKey: toolName`. An absent business registration renders `GenericToolCard`. It neither reads Session nor maintains a second call topology.
|
||||
|
||||
Business plugins register only atomic views against `'tool.call.toolview'`. Their owner payload is the standard Tool call block plus identity, cwd, and host actions; it carries no Session projector or conversation service. Skill remains an ordinary Tool and `ui-skill` registers the `skill` key through this seam. Existing first-party views live in `ui-tool` until a business package has a reason to own one independently.
|
||||
|
||||
The details panel is a second Tool presentation site but not a call-tree owner. `ui-conversation` delegates its selected output body through the single/session `'conversation.details.tool'` seat; `ui-tool` renders the card-aware output and the seat fallback preserves raw result text when the plugin is absent. Card models therefore have one production owner without introducing a reverse implementation import.
|
||||
|
||||
The Runtime remains the authority for Tool lifecycle and call topology. Code Dispatch is an official top-level concept because it changes parent/child identity; a private `ToolCallTree` shares one fold between live and history paths and projects its index into standard recursive call blocks. Ordinary Tool business differences stay at the keyed presentation seam, and this package boundary adds no Tool projector/fold registry.
|
||||
|
||||
## Runtime and render path
|
||||
|
||||
This boundary starts at the Client's `ConversationSnapshot`; the full render path is:
|
||||
|
||||
```text
|
||||
ConversationSnapshot.nodes
|
||||
-> deriveChatFlow()
|
||||
-> settled tool-group positions ----+
|
||||
|
|
||||
ConversationSnapshot.runningCalls |
|
||||
-> ChatView flow tail ---------------+-> ToolSeat
|
||||
-> conversation.chat.tool
|
||||
-> ToolCallTree
|
||||
-> root ToolCallBlock
|
||||
`- subCalls[] (recursive)
|
||||
-> tool.call.toolview(entryKey = toolName)
|
||||
|- registered atomic view
|
||||
`- GenericToolCard fallback
|
||||
```
|
||||
|
||||
Runtime's [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) privately indexes child lifecycles by parent callId and is shared by the live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) and historical [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) paths. It recursively projects children onto root `ToolCallBlock` values and copies only the owning ancestor path when a child changes. Unchanged siblings, other roots, and snapshot references with no Tool-topology change stay stable so React selectors and memoization can skip unrelated updates. Tool UI consumes this unified tree without repeating call/result pairing, historical replay, or cache indexing.
|
||||
|
||||
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) reruns [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts) only when the `nodes` reference changes. It groups consecutive settled Tool results into a `tool-group`, while running root calls append at the flow tail. Both paths ultimately enter the same `ToolSeat`, so settled and running forms share the whole-Tool seat. Selection is passed only to the root containing that call, and `ToolCallTree` then renders recursively within that local tree.
|
||||
|
||||
## Code and responsibility boundaries
|
||||
|
||||
| Owner | Primary code | Owns | Explicitly does not own |
|
||||
|---|---|---|---|
|
||||
| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts), [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts), [`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result pairing, running/settled lifecycle, recursive parent/child tree, snapshot structural sharing | Business views selected by Tool name |
|
||||
| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts), [`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx), [`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow order, settled groups, running tail, scroll anchors, selection and host actions, whole-Tool seat declaration | subcall composition, `toolName` dispatch, Generic fallback, Tool card models |
|
||||
| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts), [`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx), [`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall composition, atomic keyed dispatch, Generic fallback, Tool card models and built-in Tool views | ChatFlow ordering, Session Event fold |
|
||||
| Business Tool plugins | [`ui-skill` registration example](../../../../packages/client/ui-skill/src/client/index.ts) | Atomic views for one or more wire Tool names | root/subcall placement and lifecycle pairing |
|
||||
| Details path | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx), [`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected-call lookup, card-aware output, and raw fallback | chat call-tree composition |
|
||||
|
||||
## Slot and owner contract
|
||||
|
||||
A slot declaration also constrains render ownership. The conversation chat entry declares `'conversation.chat.tool'` through `children`, so only `ChatView` places the whole-Tool seat. When `ui-tool` registers that seat, its `children` declares `'tool.call.toolview'`, so only `ToolCallTree` renders the atomic Tool seat. Business plugins register keyed entries only; they neither participate in root/subcall composition nor establish a registry parallel to slots.
|
||||
|
||||
The whole seat's `ToolTreeOwnerProps` carries the root `callId`, `toolName`, `ToolCallBlock`, `selectedCallId`, session `cwd`, `openFile(path)`, and `inspectCall(callId)`. `ToolCallTree` converts either a root or child into the same `ToolCallOwnerProps` and narrows inspect to a callback for that call. The atomic owner carries no `ReactNode`, Cordis `Context`, Session service, or projector; a business view consumes only one standard call block and host actions.
|
||||
|
||||
The seat filler also preserves the conversation DOM contract on every root and child wrapper: `data-chat-anchor-key="call:<callId>"`, `data-chat-call-id`, and `data-selected="true"` on the selected call. `ChatView` consumes the anchor key to restore prepend/paging position; the Tool owner emits it because it alone composes child wrappers.
|
||||
|
||||
Business plugins use one registration shape:
|
||||
|
||||
```text
|
||||
ctx.slots.inject('tool.call.toolview', () =>
|
||||
ctx.slots.register({
|
||||
name: 'tool.call.toolview',
|
||||
key: '<wire tool name>',
|
||||
}, BusinessToolRow))
|
||||
```
|
||||
|
||||
`ui-tool`'s [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the whole-Tool renderer, details renderer, and existing built-in atomic views. An existing independent business package can move only its keyed registration, as `ui-skill` does, without changing `ui-conversation` or Session.
|
||||
|
||||
## Details path
|
||||
|
||||
[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) locates the selected call recursively in `nodes` and `runningCalls` through their `subCalls`, and it owns input arguments, empty states, and panel lifecycle. It passes only `{ block, cwd }` to `'conversation.details.tool'`; [`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) reuses Tool card models to render the output. When `ui-tool` is absent, a settled call falls back to raw result text and a running call shows conversation's running fallback, so details never imports the Tool implementation in reverse.
|
||||
|
||||
## Verification
|
||||
|
||||
Test ownership follows production ownership. `ui-conversation` tests install a local whole-Tool seat probe and assert only ChatFlow placement, owner payload, and host contracts such as selection, open-file, and inspect; they do not import `ui-tool` production code or test helpers. `ui-tool` tests mount a real conversation host and verify root/subcall composition, keyed dispatch, generic fallback, concrete Tool UI, and plugin lifecycle.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep atomic Tool slots under every conversation view.** Rejected: each view would have to reproduce root/subcall composition, and a Tool registration would be isolated by view even though its business meaning is Tool-wide. A whole-Tool seat preserves view-owned placement while giving the call tree one owner. This supersedes the per-view placement selected by the earlier [toolview dissolution](2026-07-23-toolview-dissolution.md), while retaining its keyed-slot and no-parallel-registry decisions.
|
||||
|
||||
**Move only the Tool React components and card models.** Rejected: `ChatView` would still own Tool-name dispatch and Code Dispatch composition, so the dependency would change file paths without changing responsibility.
|
||||
|
||||
**Add business-specific Session projectors or folds.** Rejected: ordinary Tool views consume the standard call block already reconstructed by Runtime. A second registry would create two authorities for call identity and historical replay. Only a feature that changes logged topology or lifecycle earns a Runtime-level extension.
|
||||
|
||||
**Make each atomic Tool view render its own subcalls recursively.** Rejected: the atomic registrant receives one Tool call and should not know whether it is a root or child. Recursive root/child composition belongs centrally to `ui-tool`'s `ToolCallTree`.
|
||||
|
||||
**Import `ui-tool` components directly from `ui-conversation`.** Rejected: it would reverse the intended feature direction and make Tool presentation mandatory. Declared slots retain lifecycle ownership, fallback behavior, and independent plugin loading.
|
||||
|
||||
## Consequences
|
||||
|
||||
`ui-conversation` becomes independent of Tool-name business presentation while retaining ChatFlow, selection, and host interaction responsibilities. Root calls and subcalls cannot drift onto different dispatch paths, and business packages can own atomic Tool presentation without Session changes. The cost is one new Client package and two cross-package slot seams; `ui-tool` also deliberately depends on conversation's declared seats and locale namespace. The assembled Web bundle therefore mounts `ui-tool`; omitting it leaves chat Tool seats empty while the details seat keeps its raw-result fallback, without changing Session reconstruction.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Agent Note: Client Tool 展示所有权
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-08-client-tool-presentation-ownership.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Client Runtime 已经把 Tool 调用投影成稳定的生命周期:它按 `callId` 配对 call/result 事件,保留 running 与 settled 两种形态,并按 root call 索引 Code Dispatch 子调用。但 chat view 仍拥有整套展示链路:它在 ChatFlow 中放置 root call,把每个 root 与 subcall 编排在一起,按 Tool 名称分发每个原子调用,携带通用 fallback 与 card model,注册第一方 Tool view,并在 details panel 中复用这些 model。
|
||||
|
||||
这种所有权迫使 `ui-conversation` 解释业务 Tool 名称;一旦原子 Tool view 被迁走,subcall 就会成为无主的遗留关注点。`ui-skill` 等业务包虽能注册一行视图,仍依赖 conversation 的 Tool 专属编排契约。增加 Tool 专属 Session projection 会重复 Runtime 已拥有的数据模型,而只移动单个 React 组件则会把编排与 model 耦合留在原地。
|
||||
|
||||
## Decision
|
||||
|
||||
Tool 成为 Client UI 的一级概念,并由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有展示。Runtime 将 Code Dispatch 规范化为递归 `ToolCallBlock`:每个 root 或 child 通过自己的 `subCalls` 拥有下一层调用,`ConversationSnapshot` 不再公开单独的 parent-to-children map。
|
||||
|
||||
这里的“一级概念”只描述 UI 所有权,不增加 Runtime 数据种类。`ConversationNode` 仍是 transcript projection,`ChatFlowItem` 仍是 conversation 对节点进行排序与分组后得到的渲染单元,`ToolCallBlock` 仍是单次调用的标准数据,而 `ToolCallTree` 只负责 Tool 内部的 root/subcall 展示编排。Command 继续通过独立的 `'conversation.chat.commandview'` 席位渲染,不并入 Tool。
|
||||
|
||||
`ui-conversation` 拥有有序放置。`deriveChatFlow()` 仍决定 settled Tool group 在哪里出现,`ChatView` 仍追加 running call、维护滚动 anchor 与 selection,并提供宿主动作。对于每个 root call,它使用 root block、selected call id、session cwd 以及 open-file/inspect 回调渲染 single/session 的 `'conversation.chat.tool'` 席位。它不读取 Code Dispatch child、不按 Tool 名称分支,也不导入 Tool 专属 view 或 card model。
|
||||
|
||||
`ui-tool` 占据这个整体 Tool 席位。`ToolCallTree` 直接递归遍历 root block 的 `subCalls`,并让每一层调用都通过同一个 keyed/session 的 `'tool.call.toolview'` 子 slot,以 `entryKey: toolName` 分发。业务未注册时渲染 `GenericToolCard`。它不读取 Session,也不维护第二份调用拓扑。
|
||||
|
||||
业务插件只对 `'tool.call.toolview'` 注册原子 view。其 owner payload 是标准 Tool call block 加 identity、cwd 与宿主动作,不携带 Session projector 或 conversation service。Skill 仍是普通 Tool,`ui-skill` 通过该 seam 注册 `skill` key。现有第一方 view 暂留在 `ui-tool`,直到某个业务包确有理由独立拥有它。
|
||||
|
||||
details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 通过 single/session 的 `'conversation.details.tool'` 席位委托 selected output body;`ui-tool` 渲染能够识别 card 的输出,插件缺席时由席位 fallback 保留 raw result text。因此 card model 只有一个生产代码所有者,也不需要引入反向实现依赖。
|
||||
|
||||
Runtime 仍是 Tool 生命周期与调用拓扑的权威。Code Dispatch 作为官方顶级概念改变 parent/child identity;私有 `ToolCallTree` 对 live 与 history 共用同一套 fold,并把索引投影成标准递归 call block。普通 Tool 业务差异停留在 keyed 展示 seam,这个包边界不会增加 Tool projector/fold registry。
|
||||
|
||||
## Runtime 与渲染链路
|
||||
|
||||
这项边界从 Client 的 `ConversationSnapshot` 开始,完整渲染链路如下:
|
||||
|
||||
```text
|
||||
ConversationSnapshot.nodes
|
||||
-> deriveChatFlow()
|
||||
-> settled tool-group positions ----+
|
||||
|
|
||||
ConversationSnapshot.runningCalls |
|
||||
-> ChatView flow tail ---------------+-> ToolSeat
|
||||
-> conversation.chat.tool
|
||||
-> ToolCallTree
|
||||
-> root ToolCallBlock
|
||||
`- subCalls[] (recursive)
|
||||
-> tool.call.toolview(entryKey = toolName)
|
||||
|- registered atomic view
|
||||
`- GenericToolCard fallback
|
||||
```
|
||||
|
||||
Runtime 的 [`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts) 私下按 parent callId 索引 child lifecycle,并供 Live [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) 与历史 [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) 共用。它把 children 递归投影到 root `ToolCallBlock`,child 变化时只复制所属祖先路径;未变化的 sibling、其他 root,以及没有 Tool 拓扑变化的 snapshot 引用保持稳定,供 React selector 与 memo 跳过无关更新。Tool UI 直接消费这两个路径统一后的树,不重复 call/result 配对、历史 replay 或缓存索引。
|
||||
|
||||
[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只在 `nodes` 引用变化时重新执行 [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts),把连续 settled Tool result 合为 `tool-group`;running root call 则追加在 flow tail。两条路径最终都进入同一个 `ToolSeat`,因此 settled/running 形态共享整体 Tool 席位。selection 只传给包含该 call 的 root,`ToolCallTree` 再沿该 root 的局部树递归渲染。
|
||||
|
||||
## 代码与职责边界
|
||||
|
||||
| 所有者 | 主要代码 | 拥有的责任 | 明确不拥有 |
|
||||
|---|---|---|---|
|
||||
| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts)、[`ToolCallTree`](../../../../packages/client/runtime/src/client/sessions/tool-call-tree.ts)、[`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result 配对、running/settled 生命周期、递归 parent/child 树、snapshot 结构共享 | Tool 名称对应的业务视图 |
|
||||
| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts)、[`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx)、[`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow 顺序、settled group、running tail、scroll anchor、selection 与宿主动作、整体 Tool 席位声明 | subcall 组合、按 `toolName` 分发、Generic fallback、Tool card model |
|
||||
| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts)、[`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx)、[`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall 组合、原子 keyed dispatch、Generic fallback、Tool card model 与内置 Tool view | ChatFlow 排序、Session Event fold |
|
||||
| 业务 Tool 插件 | [`ui-skill` 注册例](../../../../packages/client/ui-skill/src/client/index.ts) | 一个或多个 wire Tool name 的原子 view | root/subcall 位置与生命周期配对 |
|
||||
| details 路径 | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx)、[`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected call 定位、card-aware output 与 raw fallback | chat 调用树编排 |
|
||||
|
||||
## Slot 与 owner 契约
|
||||
|
||||
slot 声明同时限定渲染所有权。conversation chat entry 通过 `children` 声明 `'conversation.chat.tool'`,因此只有 `ChatView` 放置整体 Tool 席位;`ui-tool` 注册该席位时再通过 `children` 声明 `'tool.call.toolview'`,因此只有 `ToolCallTree` 渲染原子 Tool 席位。业务插件只注册 keyed entry,不参与 root/subcall 编排,也不建立与 slot 平行的 registry。
|
||||
|
||||
整体席位的 `ToolTreeOwnerProps` 携带 root `callId`、`toolName`、`ToolCallBlock`、`selectedCallId`、session `cwd`、`openFile(path)` 与 `inspectCall(callId)`。`ToolCallTree` 把 root 或 child 转成相同的 `ToolCallOwnerProps`,并把 inspect 收窄成当前 call 的回调。原子 owner 不携带 `ReactNode`、Cordis `Context`、Session service 或 projector;业务 view 只消费一个标准调用块和宿主动作。
|
||||
|
||||
席位填充方还要在每个 root 和 child wrapper 上保留 conversation DOM 契约:`data-chat-anchor-key="call:<callId>"`、`data-chat-call-id`,以及 selected call 上的 `data-selected="true"`。`ChatView` 用 anchor key 恢复 prepend/paging 位置;child wrapper 由 Tool owner 独自编排,因此这些属性也由它输出。
|
||||
|
||||
业务插件遵循同一个注册形态:
|
||||
|
||||
```text
|
||||
ctx.slots.inject('tool.call.toolview', () =>
|
||||
ctx.slots.register({
|
||||
name: 'tool.call.toolview',
|
||||
key: '<wire tool name>',
|
||||
}, BusinessToolRow))
|
||||
```
|
||||
|
||||
`ui-tool` 的 [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册整体 Tool renderer、details renderer 与现有内置原子 view;已有独立业务包可以像 `ui-skill` 一样只迁走自己的 keyed 注册,无需改动 `ui-conversation` 或 Session。
|
||||
|
||||
## Details 路径
|
||||
|
||||
[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) 在 `nodes` 与 `runningCalls` 的递归 `subCalls` 中定位 selected call,并拥有 input 参数、空态和面板生命周期。它只把 `{ block, cwd }` 交给 `'conversation.details.tool'`;[`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) 复用 Tool card model 渲染 output。`ui-tool` 缺席时,settled call 回退为 raw result text,running call 显示 conversation 的 running fallback,因此 details 不反向导入 Tool 实现。
|
||||
|
||||
## Verification
|
||||
|
||||
测试归属跟随生产所有权。`ui-conversation` 的测试安装本地整体 Tool 席位替身,只验证 ChatFlow 位置、owner payload 与 selection、open-file、inspect 等宿主契约;它们不导入 `ui-tool` 的生产实现或测试 helper。`ui-tool` 的测试挂载真实 conversation 宿主,验证 root/subcall 编排、keyed dispatch、generic fallback、具体 Tool UI 与插件生命周期。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在每个 conversation view 下保留原子 Tool slot。** 拒绝:每个 view 都必须重复 root/subcall 编排,而且 Tool 注册会按 view 隔离,即使它的业务语义本应是 Tool 级。整体 Tool 席位保留 view 对放置位置的所有权,同时让调用树只有一个所有者。它取代了早期 [toolview 溶解](2026-07-23-toolview-dissolution.md)所选择的 per-view 放置方式,但保留 keyed slot 与不设平行 registry 的决策。
|
||||
|
||||
**只移动 Tool React 组件与 card model。** 拒绝:`ChatView` 仍会拥有 Tool 名称分发与 Code Dispatch 编排,只是改变文件路径,没有改变责任。
|
||||
|
||||
**增加业务专属 Session projector 或 fold。** 拒绝:普通 Tool view 消费 Runtime 已重建的标准 call block。第二套 registry 会为 call identity 与历史 replay 建立两个权威。只有会改变日志拓扑或生命周期的能力才应获得 Runtime 级扩展。
|
||||
|
||||
**让每个原子 Tool view 递归渲染自己的 subcall。** 拒绝:原子注册方只接收一个 Tool call,不应知道自己是 root 还是 child。递归 root/child 编排统一归 `ui-tool` 的 `ToolCallTree`。
|
||||
|
||||
**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转预期的 feature 依赖方向,并把 Tool 展示变成必选能力。声明式 slot 能保留生命周期所有权、fallback 行为与独立插件装载。
|
||||
|
||||
## Consequences
|
||||
|
||||
`ui-conversation` 不再依赖 Tool 名称对应的业务展示,同时保留 ChatFlow、selection 与宿主交互责任。root call 与 subcall 不会漂移到不同分发路径,业务包无需修改 Session 即可拥有原子 Tool 展示。代价是新增一个 Client package 与两个跨包 slot seam;`ui-tool` 也明确依赖 conversation 声明的席位与 locale namespace。因此组装后的 Web bundle 会挂载 `ui-tool`;省略该插件时,chat Tool 席位为空,details 席位则保留 raw-result fallback,且 Session 重建不受影响。
|
||||
Reference in New Issue
Block a user