mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
Merge remote-tracking branch 'origin/master' into feat/telemetry-otel-plugin
Resolutions: regenerate docs/capability-seams.md (the only conflict); master widened the bilingual pairing scope to package READMEs, so this merge adds the Chinese counterparts and i18n records for the three telemetry READMEs and patches packages/README.zh.md with the telemetry group row.
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
|
||||
README.md: 4db9f16956b9c569cf5f9b53f04cb650f6058668
|
||||
README.zh.md: 60ec5421e7f271460daebc966aa6548f6ef8a511
|
||||
README.md: d2f6d216b151673d818337c67a78dbe908786c8b
|
||||
README.zh.md: 4c7f785ba7478f35cade742409d87746ddcdf8ec
|
||||
|
||||
@@ -37,7 +37,11 @@ The `architecture` / `process` line: **architecture** is about the source we shi
|
||||
|
||||
Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a cross-file or cross-package contract, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)).
|
||||
|
||||
Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one and cross-link. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).
|
||||
Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).
|
||||
|
||||
An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, verification contract, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale.
|
||||
|
||||
A feature-addition note may be consolidated into the later removal note only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that verify absence may remain. The removal owner preserves the original motivation, why it no longer justified the feature, alternatives to full removal, the capability given up, conditions for reintroduction, and verification of complete absence. Obsolete implementation inventories and tests that only verified the deleted behavior are not current verification contracts. Removing one transport, default, implementation, or presentation is partial supersession, as is any surviving durable data or compatibility handling.
|
||||
|
||||
## The file format
|
||||
|
||||
|
||||
@@ -39,7 +39,11 @@
|
||||
|
||||
每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
|
||||
|
||||
更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧的,并互相链接。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
|
||||
更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
|
||||
|
||||
被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、验证契约和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。
|
||||
|
||||
只有当一项功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中完全消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行时,新增该功能的 Agent Note 才可合并进后续的移除记录。移除决策的依据和验证该功能已不存在的测试可以保留。移除决策的持有记录必须保留最初动机、为什么该动机已不足以证明保留该功能的合理性、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。过时的实现清单和只验证已删除行为的测试不属于当前验证契约。仅移除一种传输、默认值、实现或展示属于部分取代;仍有任何持久数据或兼容处理也同样如此。
|
||||
|
||||
<a id="the-file-format"></a>
|
||||
|
||||
|
||||
@@ -8,4 +8,4 @@ Keep paths, symbols, defaults, and mechanisms current in the same change that al
|
||||
|
||||
### This is not a license to rewrite the *decision*
|
||||
|
||||
Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; see the [Agent Note contract](../README.md).
|
||||
Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; a fully superseded old note may be deleted only through the consolidation rule in the [Agent Note contract](../README.md).
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Agent Note: Custom typed tool-schema DSL instead of schemastery
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-06-11-custom-schema-dsl.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Tool parameters must reach the model as standard JSON Schema while giving tool authors typed `execute(args)` without casts. Schemastery already serves plugin config, but the tool-author API needs per-property `required: true` booleans rather than JSON Schema's separate `required` array.
|
||||
|
||||
## Decision
|
||||
|
||||
This decision is superseded by the [unified JSON-value schema DSL](2026-07-20-unified-json-value-schema-dsl.md), which retains the small authoring surface while making parameters and typed values share one vocabulary. `ParameterSchemaSpec` keeps per-property `required: true`; `InferArgs<S>` maps required keys to non-optional properties; `parameterSchemaSpecToJsonSchema()` compiles the implicit open object root; and `defineTool()` ties inference, compilation, and validation together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` for MCP and other external tools.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Schemastery** (already vendored, used for plugin Config) was evaluated and rejected for this use: it targets validation / transformation against StandardSchema, not JSON Schema *generation*, so it would add indirection without producing the wire format cleanly.
|
||||
|
||||
## Consequences
|
||||
|
||||
- First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy).
|
||||
- The owning unified note defines the current nodes, literal constraints, unions, JSON-value boundary, and object-openness rules.
|
||||
- The `InferArgs` mapping is regression-tested at the type level after an early optionality bug.
|
||||
@@ -1,23 +0,0 @@
|
||||
# Agent Note: 使用自定义类型化工具 schema DSL 替代 schemastery
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-11-custom-schema-dsl.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
工具参数必须以标准 JSON Schema 形式到达模型,同时让工具作者在 `execute(args)` 中获得类型化的参数而无需类型断言。Schemastery 已用于插件配置,但工具作者 API 需要逐属性的 `required: true` 布尔值,而非 JSON Schema 的独立 `required` 数组。
|
||||
|
||||
## 决策
|
||||
|
||||
该决策已由[统一 JSON 值 schema DSL](2026-07-20-unified-json-value-schema-dsl.md)取代;新设计保留小型编写接口,同时让参数与类型化值共享一套词汇。`ParameterSchemaSpec` 保留逐属性的 `required: true`;`InferArgs<S>` 将必需键映射为非可选属性;`parameterSchemaSpecToJsonSchema()` 编译隐式开放的对象根;`defineTool()` 则将类型推导、编译与校验串联起来。原始 JSON Schema 的 `ToolDefinition` 仍是 `ToolRegistry.register()` 接受的输入,供 MCP 和其他外部工具使用。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**Schemastery**(已作为 vendor 引入,用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema *生成*,因此会增加间接层却无法干净地产出协议格式(wire format)。
|
||||
|
||||
## 后果
|
||||
|
||||
- 第一方工具作者获得零类型断言的类型化参数;类型体操的成本留在核心包内部(符合 AGENTS.md 的类型安全策略)。
|
||||
- 当前节点、字面量约束、联合类型、JSON 值边界与对象开放性规则均由上述统一说明定义。
|
||||
- `InferArgs` 映射在类型层面有回归测试,源于早期一个可选性 bug。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-18-shared-persistence-write-coordinator.md: ea9c4fb74f7c1bd68fb62efedd3e1657da96ea65
|
||||
2026-06-18-shared-persistence-write-coordinator.zh.md: 3b4dd7b762c2f39a908eabe23e5d734981b5767b
|
||||
2026-06-18-shared-persistence-write-coordinator.md: 4632351a6f39c44c9ba8af58d508d4665b9e9279
|
||||
2026-06-18-shared-persistence-write-coordinator.zh.md: 40a7144038ac0db4ca6cac651c0a3cef5de4afa9
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ The coordinator retires a session from `session/disposed`: it waits for the cont
|
||||
Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage:
|
||||
|
||||
- `name` — backend label for the dispose-failure `AggregateError`.
|
||||
- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Resume/load, non-mutating inspection, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication.
|
||||
- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL project directory; SQLite's id is globally unique). Resume/load, non-mutating inspection, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication.
|
||||
- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook).
|
||||
- `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`).
|
||||
- `list()` — list all stored metadata.
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ Status: implemented
|
||||
五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界:
|
||||
|
||||
- `name`——后端标签,用于 dispose 失败时的 `AggregateError`。
|
||||
- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有 cwd bucket;SQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。
|
||||
- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。
|
||||
- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。
|
||||
- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers)和 live-adoption(仅截断,`closers = []`)。
|
||||
- `list()`——列出所有已存储的元数据。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-20-package-hierarchy.md: 7cd07ff90225872f2a17b9a678e52fcee416b09a
|
||||
2026-06-20-package-hierarchy.zh.md: 9ef89bd56144b39bb3240a22a2bb1e9216e24115
|
||||
2026-06-20-package-hierarchy.md: 4e05e3487483ab8d710959c1888ec1f5c3b37432
|
||||
2026-06-20-package-hierarchy.zh.md: f57704ad082c4961aa48056af1b4b279d2f2c055
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-06-20-package-hierarchy.zh.md)
|
||||
|
||||
The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) subsequently removed that surface entirely. The [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md) places ACP under `packages/acp/acp` instead of the human-UI group. The uniform depth-two hierarchy remains the decision owned here.
|
||||
The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) deletes the original `support/ui-stdio` surface instead of relocating it, and the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md) places ACP under `packages/acp/acp` instead of the human-UI group. The uniform depth-two hierarchy remains the decision owned here.
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
[English](2026-06-20-package-hierarchy.md) | 中文
|
||||
|
||||
后续的[折叠 stdio helper](../simplification/2026-07-04-fold-stdio-ui-helper.md)决策取代了最初的 `support/ui-stdio` 放置方式,[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)随后又彻底移除了该接口。[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md)把 ACP 放在 `packages/acp/acp` 下,而不是面向人类的 UI 组。这里拥有的决策仍是统一的二层目录深度。
|
||||
[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)直接删除最初的 `support/ui-stdio` 接口,而不是将其迁移;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md)把 ACP 放在 `packages/acp/acp` 下,而不是面向人类的 UI 组。这里拥有的决策仍是统一的二层目录深度。
|
||||
|
||||
## 问题
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-21-bounded-llm-request-recovery.md: 22a56dc6d69340ca1b5f7b77edb4731066c9b2f5
|
||||
2026-06-21-bounded-llm-request-recovery.zh.md: 09ebce376a206591ac766067cc41497b74ed1545
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-06-21-bounded-llm-request-recovery.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. The default decision is `fail`; `dsh-compact-basic` is the only shipped recovery listener, and it retries a canonical context-window overflow only after compaction proves that the durable surface shrank.
|
||||
@@ -42,7 +44,7 @@ The agent loop keeps `RequestError` as that exact error object and passes `LlmFa
|
||||
|
||||
Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them.
|
||||
|
||||
The initial shared transient-code set is intentionally small: the adapters' existing `RATE_LIMIT` and `SERVER` mappings plus explicit `TIMEOUT` and `TRANSPORT` codes for the two missing remote-failure families. Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum.
|
||||
The shared transient-code set is intentionally small: adapter mappings for `RATE_LIMIT` and `SERVER`, explicit `TIMEOUT` and `TRANSPORT` codes for remote failures, and `EMPTY_RESPONSE` for a completed provider response with no content blocks. Both adapters classify the last case as an error finish; see [empty model responses are retryable](../bug-fix/2026-07-24-empty-model-response-is-retryable.md). Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum.
|
||||
|
||||
### Put retry policy on the existing failed-step seam
|
||||
|
||||
@@ -62,7 +64,7 @@ interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets.
|
||||
The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the five transient codes above (`RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, and `EMPTY_RESPONSE`). The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets.
|
||||
|
||||
For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered.
|
||||
|
||||
@@ -124,7 +126,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason`
|
||||
- Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff.
|
||||
- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery.
|
||||
- The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance.
|
||||
- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus durable discarded-attempt markers in append-only ACP and stdio streams. Keyless snapshots cover scheduling, cancellation, success, and exhaustion.
|
||||
- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus scheduled-retry rendering. Keyless snapshots cover scheduling, cancellation, success, and exhaustion; ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted.
|
||||
- Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it.
|
||||
- Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts.
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Agent Note: LLM(大语言模型)暂时性请求失败的有界恢复
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-21-bounded-llm-request-recovery.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-loop` 能将其与中间件和结果处理缺陷区分开。循环关闭失败步骤后,会把两种交付形式统一规范化为 `agent/request-error`。默认决策为 `fail`;`dsh-compact-basic` 是唯一已交付的恢复监听器,它仅在压缩(compaction)证明持久表层已缩减后,才会对规范化的上下文窗口溢出进行重试。
|
||||
|
||||
该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号步骤。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。
|
||||
|
||||
此前的边界还留有三个较窄的缺口。
|
||||
|
||||
- 提供方失败只保留消息,通常还会保留一个 code。HTTP 状态、重试延迟和提供方请求 id 会被丢弃,或者只能通过提供方专用错误对象恢复,因此通用恢复机制如果不解析文本,便无法作出决策或解释决策。
|
||||
- 重试的归属因适配器而异。手写 DeepSeek 适配器只尝试一次,pi-ai profile 则可以启用库内部的不透明重试。如果把隐藏的传输重试与 `agent/request-error` 监听器结合,尝试次数会成倍增加,中间失败也不会记入会话日志。
|
||||
- 恢复后的失败没有持久状态事实。失败的步骤和分片仍可重建,但观察者无法得知 agent(智能体)是否在有意退避、将等待多久,以及等待原因。长时间的静默等待看起来与循环停滞无异。
|
||||
|
||||
本决策的目标是从同一个显式提供方/模型请求的暂时性失败中进行有界恢复。提供方或模型故障转移、响应拼接和语义输出修复都属于其他问题,目前没有消费方。
|
||||
|
||||
## 决策
|
||||
|
||||
### 保留失败事实,不嵌入策略
|
||||
|
||||
`@deepseek-ai/dsh-llm` 导出唯一的可 JSON 序列化 `LlmFailure` 载荷:
|
||||
|
||||
```ts ignore-check
|
||||
type ProviderRequestId = Branded<'ProviderRequestId'>
|
||||
|
||||
interface LlmFailure {
|
||||
message: string
|
||||
code: string
|
||||
status?: number
|
||||
providerRetryAfterMs?: number
|
||||
requestId?: ProviderRequestId
|
||||
}
|
||||
```
|
||||
|
||||
`code` 仍是 `HarnessError` 建立的提供方无关机器路由分类体系;新字段是在提供方边界观测到的事实。`ProviderRequestId` 由 `dsh-llm` 拥有并构造,序列化后为提供方发放的字符串。该载荷有意不包含 `retryable`、`failover`、`partialOutput`、提供方、模型、阶段或路由 id 字段。是否可重试属于策略,提供方/模型已位于持久请求头中,部分输出则从失败步骤的 `assistant/chunk` 事件派生。
|
||||
|
||||
`LlmError` 携带 `failure: LlmFailure`,并保持 `failure.code === error.code`。`FinishReasonMap.error` 和 `FinishReasonMap.aborted` 携带同一载荷,而不是并行的失败形状。适配器抛出的 `Error` 保留其精确的对象标识:最终适配器 scope 在调用局部的伴随状态中把规范化事实与该对象关联,然后原样重新抛出;非 `Error` 抛出值则依旧被包装。`llmFailureOf(stream, error)` 会在现有来源检查旁取回这些事实,而没有错误对象的带内 finish 则会成为新的 `LlmError`。这既保留了按错误类型或标识分流的监听器,又使所有最终适配器失败(包括未知 SDK 异常)都获得 `UNKNOWN` 终止载荷。
|
||||
|
||||
agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误对象,并将 `LlmFailure` 作为独立参数传给 `agent/request-error`;它不会改动可能已冻结的第三方错误。在转换带内 finish 以及记录未恢复的 `turn/end.reason` 时,循环也会使用该载荷。
|
||||
|
||||
适配器会先提取结构化事实,再回退到消息检查。它们会验证 HTTP 状态,将 `Retry-After` 的秒数或日期解析为正的有限毫秒延迟,在提供方公开请求 id 时将其品牌化,并区分自身超时与调用方中止。提供方专用 code 和消息可以细化映射,但恢复监听器不会解析它们。
|
||||
|
||||
共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。
|
||||
|
||||
### 将重试策略放在现有失败步骤 seam 上
|
||||
|
||||
`@deepseek-ai/dsh-llm-retry` 是监听 `agent/request-error` 的函数插件。它不引入服务或新的循环分支;agent-loop 包仅会更改通过现有失败步骤恢复控制流携带的数据。
|
||||
|
||||
`agent/request-error` seam 携带当前 `LlmFailure`,以及在这段连续恢复序列中导致再次请求的不可变先前失败列表。`dsh-llm-retry` 只计数 code 位于已配置暂时性集合中的先前失败,`dsh-compact-basic` 则只计数先前的上下文溢出失败。模型请求成功后会清空历史。因此,暂时性失败与上下文溢出交替出现时,两种策略会独立消耗各自预算;最大请求数等于 1 加上已加载恢复策略的有限预算总和。
|
||||
|
||||
该插件在加载时解析并验证以下部署配置:
|
||||
|
||||
```ts ignore-check
|
||||
interface Config {
|
||||
maxTransientRetries?: number
|
||||
initialDelayMs?: number
|
||||
maxDelayMs?: number
|
||||
jitterRatio?: number
|
||||
retryableCodes?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
默认值为两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code(`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT` 和 `EMPTY_RESPONSE`)。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。在没有其他恢复策略时,两次重试表示最多发起三次提供方请求。`maxTransientRetries` 是非负整数,延迟是正的有限数且满足 `initialDelayMs <= maxDelayMs`,`jitterRatio` 位于 `[0, 1]`,code 非空且不重复。这些都是 Cordis 配置字段,而不是隐藏常量,使部署能够选择不同的成本与延迟预算。
|
||||
|
||||
对于预算未耗尽的合格失败,从 1 开始的暂时性重试计数使用有界指数退避。有效的 `providerRetryAfterMs` 只有在不超过 `maxDelayMs` 时才会取代指数退避;提供方延迟更长时,系统会委托给下一监听器,而不会违反提供方指令提前重试。本地退避乘以 `[1 - jitterRatio, 1 + jitterRatio]` 内的注入随机因子,并将最终值限制到 `maxDelayMs`;提供方延迟不加抖动。
|
||||
|
||||
插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的退避回调。每次等待都会融合 waterfall(瀑布式事件)的轮次信号与该生命期信号。effect 清理会先注销监听器,再中止并等待活跃回调;被捕获回调的生命期信号中止时,回调会返回 `fail`,既不能重试,也不能在插件释放后进入其捕获 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。
|
||||
|
||||
休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、从 1 开始的暂时性重试编号、已配置上限、计划延迟和 `LlmFailure`。该插件拥有 `SessionEventMap` 声明合并;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它仅与生产渲染器及回放/快照覆盖一起交付。
|
||||
|
||||
对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。只有在两个信号下完成延迟后,它才会返回 `{ action: 'retry' }`;轮次取消和插件释放会返回 `fail`,此后仍以循环的取消/释放检查为准。
|
||||
|
||||
agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一有界策略。库消费方仍需显式组合插件:省略该插件时,`agent/request-error` 保持现有的 fail 默认值。
|
||||
|
||||
### 由单一层负责可见的尝试
|
||||
|
||||
适配器每次调用 `stream()` 只执行一次提供方请求。pi-ai 适配器移除公开的 `maxRetries` 和 `maxRetryDelayMs` profile 字段,并禁用库内部重试;手写适配器保持现有的单次尝试行为。这样既避免 SDK 预算成倍放大 agent 预算,又能确保每次暂时性重试都由一个已关闭的失败步骤加 `llm/retry` 表示。
|
||||
|
||||
`ctx.llm.stream()` 仍是原始的单次尝试 waterfall。压缩摘要等直接调用方会收到结构化失败,但不会自动获得重试,因为它们没有 agent 步骤边界,也没有可供分隔尝试的通用持久位置。未来的直接调用消费方可能会需要一个缓冲辅助函数,仅在尚未发出任何分片时重试;本决策不增加此类辅助函数。
|
||||
|
||||
### 在能够终止停滞流的位置施加边界
|
||||
|
||||
每个适配器都公开一个经过验证的 `streamIdleTimeoutMs` 配置字段,默认值采用上文引用的五分钟先例。该间隔不超过 Node 的最大定时器延迟,因此不会被钳制为 1 毫秒。它覆盖每个尚未完成的迭代器 `next()`:从消费方请求下一项开始,到下一条有效 `StreamChunk` 到达为止;消费方在两次 `next()` 调用之间花费的时间不属于提供方空闲时间。
|
||||
|
||||
`@deepseek-ai/dsh-timeout` 公开一个可重新布防的空闲看门狗原语。一个稳定的局部 `AbortController` 会与调用方信号融合,并在整个适配器调用期间传给传输层;每个尚未完成的 `next()` 都会布防看门狗,该调用完成时解除布防,下一次请求数据时再重新布防。超时会使用能力自身拥有的 `TimeoutReason` 中止这个稳定控制器,`finally` 则会清除定时器。适配器将自身看门狗归类为 `TIMEOUT`,将更早发生的上游中止归类为 `ABORTED`。现有的一次性 `deadline()` 不会被描述为滑动计时器。
|
||||
|
||||
边界测试证明两个实际传输层都能终止。手写适配器会中止其 fetch/reader,pi-ai 适配器会把稳定信号映射到 SDK,并证明 SDK 会关闭响应。如果定时器只拒绝消费方 promise,却让请求继续运行,就不满足此契约。
|
||||
|
||||
### 在现有日志中分隔尝试
|
||||
|
||||
一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会开启下一个编号步骤,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录终止失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。
|
||||
|
||||
如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 自动提供方或模型故障转移。请求已显式选择一个提供方和模型,提供方注册表也有意规定每个提供方只由一个适配器负责。
|
||||
- 在成功的终止性 finish 后重试或继续,或将两次尝试的分片拼接成一条 assistant 消息。
|
||||
- 修复格式错误的工具参数、拒答、内容过滤或其他语义模型输出。
|
||||
- 无界重试、无人值守地持续重试直至取消、熔断器、共享提供方健康状态或跨 agent 重试预算。
|
||||
- 在没有生产消费方的情况下,把 `llm/stream` 改造成响应生命周期或增加便利的生成 API。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **在 `llm/stream` 或提供方 SDK 内部重试**:拒绝采用,因为原始流一旦发出分片便没有持久尝试边界,隐藏的 SDK 重试会成倍放大预算,而且两条路径都无法一致地记录每次失败尝试。
|
||||
- **向 `dsh-llm` 增加响应开始、中断、丢弃、失败和提交事件**:拒绝采用,因为 agent 日志已经分隔原始分片、成功消息和编号尝试。第二套状态机会重复归属关系,又不能支持有界的同路由重试。
|
||||
- **增加逻辑路由、能力矩阵和故障转移选择**:拒绝采用,因为当前请求已经显式指定提供方和模型,每个提供方由一个适配器负责,而且没有当前消费方要求自动回退或能够证明语义兼容性。
|
||||
- **把 `retryable` 或 `failover` 放在 `LlmFailure` 上**:拒绝采用,因为适配器报告事实,部署策略决定动作。同一个 429 可以在交互式组合包中重试,也可以在成本受限的批处理中被拒绝。
|
||||
- **只要调用方仍处于活跃状态就无限重试**:拒绝采用,因为这会让一次请求产生无界成本和延迟。可见状态能使有界等待易于理解,却不能让无限预算变得安全。
|
||||
- **只通过进程 logger 记录重试状态**:拒绝采用,因为进程日志无法重建会话行为,也不能驱动回放后的 UI 状态。
|
||||
- **只保留扁平 code**:拒绝采用,因为重试延迟和提供方请求 id 是结构化的提供方事实,而当不同协议失败共用一个稳定 code 时,诊断还需要 HTTP 状态。
|
||||
|
||||
## 验证
|
||||
|
||||
- `LlmFailure` 是最终适配器抛出失败、错误 finish 和中止 finish 使用的唯一可序列化载荷;在可用时,规范化保留稳定 code、状态、重试延迟、品牌化的提供方请求 id、错误原因,以及调用方中止与适配器超时之间的分类。
|
||||
- 适配器抛出的 `Error` 会以完全相同的对象抵达 `agent/request-error`,其伴随的 `LlmFailure` 则抵达相邻参数;测试保留针对可扩展及冻结第三方错误的现有对象标识断言。
|
||||
- DeepSeek 和 pi-ai 适配器测试覆盖具有代表性的 400、401/403、429、5xx、连接、格式错误/截断流、超时、中止、Retry-After 秒数/日期、请求 id 和未知 SDK 错误路径,恢复策略无需解析消息文本。
|
||||
- Pi 将 SDK 选项固定为零次重试,并针对可重试的提供方响应执行一次可观测的实际网络请求;独立测试确保移除任一边界都会失败。
|
||||
- `agent/request-error` 携带当前失败事实以及不可变的先前已重试失败事实;成功会清除该历史,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。
|
||||
- `dsh-llm-retry` 在 Loader 启动时验证每个配置字段,使用 `next()` 委托所有不合格路径,而且在没有其他策略时最多发起 `maxTransientRetries + 1` 次提供方请求。
|
||||
- 退避期间执行 HMR 的测试证明:释放过程会注销监听器、中止并等待其捕获的回调,释放后不发出重试决策,也不留下存活的定时器或 promise。
|
||||
- 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。
|
||||
- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新步骤中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。
|
||||
- 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。
|
||||
- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 撤回和计划重试渲染。无密钥快照覆盖调度、取消、成功和耗尽;ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。
|
||||
- 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。
|
||||
- `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。
|
||||
|
||||
## 后果
|
||||
|
||||
- 每次暂时性恢复尝试都以一个已关闭步骤加 `llm/retry` 的形式可见,有界策略还会防止隐藏的 SDK 重试成倍增加成本。即使没有分片到达,重试仍可能造成提供方重复计费;有限的尝试预算只能限制而无法消除此风险。
|
||||
- 提供方 SDK 可能隐藏状态或重试标头。适配器会保留 SDK 公开的稳定事实,否则使用粗粒度 code,而不会让恢复策略解析脆弱的文本。
|
||||
- 持久重试事件扩展了会话协议和 UI 状态机。事件与其消费方一同交付,可避免产生无人使用的遥测词汇;但以后更改 schema 仍需要同步完成持久化和回放工作。
|
||||
- 清除失败步骤的实时分片可能会明显撤回输出。与把丢弃的文本或不完整工具 JSON 呈现为已提交历史相比,这是更好的选择;快照固定这一转换。
|
||||
- 适配器局部的空闲强制机制可以终止停滞的传输,而不会计入消费方思考时间。每个传输边界的契约测试会防止 SDK 漂移。
|
||||
- 多个恢复插件会叠加各自的有限预算。此处它们的分类器互不重叠;重叠的分类器会形成依赖注册顺序的策略,必须由引入它们的插件记录并测试。
|
||||
|
||||
## 相关资料
|
||||
|
||||
- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md)负责稳定、可供机器路由的 code 与 cause chaining。
|
||||
- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)使提供方/模型和完整请求输入在分发前持久化。
|
||||
- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。
|
||||
- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)负责当前已关闭步骤的请求恢复 seam 与有界溢出重试。
|
||||
- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-02-tool-render-intent-union.md: 6cfd8921decbe16343f963574edd52173c2f8698
|
||||
2026-07-02-tool-render-intent-union.zh.md: d0414c5f15995192df898e968d054933f82d2ab4
|
||||
2026-07-02-tool-render-intent-union.md: 84423e9000526848a111591c1bb2ab92067bbe50
|
||||
2026-07-02-tool-render-intent-union.zh.md: 43873c622fc8483b4a7033d17b4b4fab1342a56b
|
||||
|
||||
@@ -54,6 +54,8 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
|
||||
|
||||
`TerminalResultView` carries only `output`/`exitCode`/`signal`. A UI without the terminal capability needs a fenced ` ```console ` text fallback; that derivation moves to the **bridge** (it wraps `output` in a fenced block on the no-capability path), rather than the tool double-encoding it. This keeps the bash tool's result a single structured shape and preserves the existing capability-gated behavior byte-for-byte.
|
||||
|
||||
The terminal intent is display-only. The harness still executes the command through its bash service, preserving sandboxing, environment scrubbing, task ownership, and per-session cwd; a UI projects the completed call and never becomes a second execution backend.
|
||||
|
||||
### Purity preserved
|
||||
|
||||
`presentCall`/`presentResult` remain pure functions of `args` (+ the result for `presentResult`) — they run on live streaming AND session-log replay, so they must be replay-deterministic. Every view is derived from args alone: write's diff is new-file style (`oldText:null`) because the tool has no old content at call time; edit's diff is `old_string`→`new_string`.
|
||||
@@ -61,6 +63,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
|
||||
## Alternatives considered
|
||||
|
||||
- **Delete tool-owned presentation entirely** — [the rejected collapse proposal](../../rejected/simplification/2026-06-20-generic-tool-rendering.md); its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met.
|
||||
- **Let a UI execute terminal intents** — rejected because it would bypass the harness's bash policy and ownership contracts and fork command execution across backends. A terminal card describes harness-owned execution; it never authorizes client-side execution.
|
||||
- **A merge-extensible union** (the `ContentBlockMap` pattern) — rejected: a new render intent needs new bridge code to render it anyway, so a plugin-added variant the bridge silently drops would be worse than the compile error the closed union raises at the bridge's `assertNever` switch.
|
||||
- **Keeping the optional-field bag** — the status quo the Problem dissects: invalid states representable, undocumented field interactions, and no way to ask for a diff card at all.
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
|
||||
|
||||
`TerminalResultView` 只携带 `output`/`exitCode`/`signal`。不具备终端能力的 UI 需要一个围栏 ` ```console ` 文本回退;该推导移至 **bridge**(在无能力路径上将 `output` 包裹在围栏代码块中),而非由工具双重编码。这使 bash 工具的结果保持单一结构化形状,并逐字节保留既有的能力门控行为。
|
||||
|
||||
terminal 意图只用于展示。harness 仍通过自身的 bash 服务执行命令,从而保留沙箱、环境清理、任务归属和每会话 cwd;UI 只呈现已完成的调用,绝不会成为第二个执行后端。
|
||||
|
||||
### 纯函数性保持不变
|
||||
|
||||
`presentCall`/`presentResult` 仍然是 `args`(`presentResult` 还有 result)的纯函数——它们在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性。每个 view 仅从 args 推导:write 的 diff 是新文件风格(`oldText:null`),因为工具在调用时没有旧内容;edit 的 diff 是 `old_string`→`new_string`。
|
||||
@@ -61,6 +63,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其自身的结论正是推迟到两个真实工具和两个真实消费方存在后再做此联合类型,该条件现已满足。
|
||||
- **让 UI 执行 terminal 意图**:否决。这样会绕过 harness 的 bash 策略与归属契约,并把命令执行分裂到不同后端。terminal 卡片描述的是 harness 拥有的执行,绝不授权客户端侧执行。
|
||||
- **可合并扩展的联合类型**(`ContentBlockMap` 模式):否决。新的渲染意图无论如何需要新的 bridge 代码来渲染,因此一个被 bridge 静默丢弃的插件添加变体,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟糕。
|
||||
- **保留可选字段集合**:即「问题」一节所剖析的现状:无效状态可表达、字段交互无文档、且完全无法请求 diff 卡片。
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Agent Note: Windows write-permission semantics — inherited DACLs, not mode bits
|
||||
|
||||
Status: implemented
|
||||
|
||||
The replacement-file decision in this record is superseded by [Windows DACL preservation](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md).
|
||||
|
||||
## Problem
|
||||
|
||||
`writeFileAtomic` in `@deepseek-ai/dsh-fs-local` protects write-in-progress content with POSIX mode bits: the staging directory is created `0o700`, the temp file is opened `0o600`, and new files default to `0o600`. On POSIX this keeps temporary content owner-only regardless of the parent directory's permissions.
|
||||
|
||||
Windows has no working equivalent behind the same API. Node's `chmod` there drives only the read-only attribute (every mode this package passes carries owner-write, so the calls are benign no-ops), and `stat().mode` reports synthetic `0o666`/`0o444` bits. The real security state is the file's DACL: a newly created file or directory inherits from its parent, while replacement needs the explicit handling owned by the superseding Agent Note.
|
||||
|
||||
## Decision
|
||||
|
||||
New Windows files use directory inheritance rather than synthetic mode bits: the staging directory is created inside the target's parent directory (`dirname(absolutePath)`), so it and the temp file inherit the destination directory's DACL. Replacement files follow the stricter [DACL preservation contract](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md).
|
||||
|
||||
Tests assert mode bits on POSIX only. Native Windows coverage pins the package-owned replacement behavior; new-file inheritance remains an operating-system contract rather than a machine-specific ACL allowlist.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Explicit owner-only DACLs for new files.** Rejected because they would break inheritance and surprise users whose project directories are deliberately shared. Replacement writes copy the target's existing DACL rather than inventing an owner-only policy.
|
||||
|
||||
**Test-side ACL verification.** A `Get-Acl` SID allowlist or `icacls` would verify Windows inheritance and the machine's `%TEMP%` ACL rather than package behavior; `icacls` also localizes well-known account names, making parsing locale-fragile.
|
||||
|
||||
**Skip `chmod` on Windows.** Platform-guarding benign no-op calls adds branches without changing behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
POSIX keeps owner-only temp content regardless of the parent directory. A new Windows target inside a broadly accessible directory inherits that accessibility by design; a replacement retains the target's narrower DACL when one exists.
|
||||
|
||||
Mode preservation across a replace degenerates to a no-op on Windows: a writable file probes as `0o666`, and replaying that through `chmod` leaves the read-only attribute clear. A read-only target cannot be replaced there because publication fails before the synthetic mode would matter.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-05-windows-jsonl-durable-publish.md: 38c4adc7a4f85d45e53e70fcac84073ab4e50775
|
||||
2026-07-05-windows-jsonl-durable-publish.zh.md: 8dc77a0ab1b9273cf3f6ecb26916c7861ee81ec4
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-05-windows-jsonl-durable-publish.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-session-persistence-jsonl` publishes a session log lazily on the first append. The POSIX protocol writes a temp file, fsyncs it, links it to the final name, fsyncs the parent directory, and then removes the temp link. The parent-directory fsync is part of the durability contract: a crash after the namespace change must not lose the committed final name while leaving callers believing the session log materialized.
|
||||
@@ -12,9 +14,9 @@ Windows has atomic namespace operations, but Node does not expose a POSIX-equiva
|
||||
|
||||
The JSONL backend forks inside `materialize()` before any namespace mutation. Shared code computes the session directory, final log path, and encoded header plus initial event batch; POSIX and Windows then run separate publication protocols.
|
||||
|
||||
POSIX keeps the existing protocol: create the root and cwd bucket with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the bucket directory, then remove the redundant temp hard link.
|
||||
POSIX keeps the existing protocol: create the root, project directory, and session directory with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the session directory, then remove the redundant temp hard link.
|
||||
|
||||
Windows creates missing directories through a durable staging publish: create a random sibling directory, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules.
|
||||
Windows creates missing directories through a durable staging publish: create a random sibling directory under the constant `.dsh-mkdir-` prefix, independent of the target basename, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,6 +30,6 @@ Windows creates missing directories through a durable staging publish: create a
|
||||
|
||||
The backend keeps one external contract across platforms: first append either publishes a complete log at the final name or fails without overwriting an existing log. The platform split is an implementation detail; `SessionPersistence` APIs and the logical JSONL record format do not change. The later [Zstandard encoding decision](2026-07-19-zstandard-jsonl-session-logs.md) applies before either platform publishes the opaque bytes.
|
||||
|
||||
Windows tests exercise the real Win32 publish path on native Windows. Power-loss behavior remains an API-contract property rather than something unit tests can prove; the testable invariants are that directory fsync is not called on Windows materialization, final-path collisions fail, temp logs are fsync'd before publication, and the resulting log loads normally.
|
||||
Windows tests exercise the real Win32 publish path on native Windows. Power-loss behavior remains an API-contract property rather than something unit tests can prove; the testable invariants are that directory fsync is not called on Windows materialization, final-path collisions fail, maximum-length target components remain materializable, temp logs are fsync'd before publication, and the resulting log loads normally.
|
||||
|
||||
Append and repair still use ordinary file-handle fsyncs on both platforms. A failed append closes its append-only handle, reopens the log read/write, truncates it to the pre-append size, and fsyncs the rollback because Windows rejects `ftruncate` on append-only handles.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Windows 原生持久 JSONL 发布
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-05-windows-jsonl-durable-publish.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`dsh-session-persistence-jsonl` 在首次追加时延迟发布会话日志。POSIX 协议会写入临时文件,对其执行 fsync,将其链接至最终名称,对父目录执行 fsync,然后移除临时链接。对父目录执行 fsync 是持久性契约的一部分:命名空间变更后发生崩溃时,已经提交的最终名称不能丢失,否则调用方会误以为会话日志已经物化。
|
||||
|
||||
Windows 具备原子命名空间操作,但 Node 没有暴露与 POSIX 等价的父目录 fsync 契约。如果把 Windows 目录同步失败视为成功,就会在无提示的情况下削弱持久化后端。因此,Windows 路径需要采用不同的发布原语,而不是在 POSIX 的 `syncDir` 辅助函数中添加条件分支。
|
||||
|
||||
## 决策
|
||||
|
||||
JSONL 后端会在 `materialize()` 内部、任何命名空间变更之前分流。共享代码计算会话目录、最终日志路径,以及编码后的 header 和初始事件批次;随后 POSIX 与 Windows 分别执行各自的发布协议。
|
||||
|
||||
POSIX 保留现有协议:创建根目录、项目目录与会话目录,并对其父目录执行 fsync;写入临时文件并对其执行 fsync;使用 `link()` 发布,确保绝不覆盖已有的最终日志;对会话目录执行 fsync;最后移除多余的临时硬链接。
|
||||
|
||||
Windows 通过持久的暂存发布来创建缺失目录:在固定的 `.dsh-mkdir-` 前缀下创建一个随机同级目录,其名称与目标基本名无关;随后使用 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 将其发布为最终目录名称,且不使用 `MOVEFILE_REPLACE_EXISTING` 或 `MOVEFILE_COPY_ALLOWED`。文件物化先写入临时日志并对其执行 fsync,再以同一个启用写穿透的 `MoveFileExW` 调用将临时文件发布到最终路径,并且同样不允许替换。`koffi` 是覆盖这组 API 所需的最小 Win32 桥接层;`pnpm-workspace.yaml` 允许执行它的安装脚本,因为该包(package)会分发原生 loader 和预构建的平台模块。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**忽略 Windows 目录同步失败。** 不予采纳,因为这会在没有强制将已发布的命名空间条目写入稳定存储时,就把首次追加报告为持久化成功。
|
||||
|
||||
**使用 `CreateHardLinkW`。** 不予采纳,因为硬链接依赖文件系统、不能发布目录,并且没有提供写穿透选项。
|
||||
|
||||
**使用替换或事务型 API。** `ReplaceFileW` 的替换语义与拒绝同一 id 冲突的要求相悖,而新应用设计不应使用 Transactional NTFS。
|
||||
|
||||
## 影响
|
||||
|
||||
该后端在各平台上维持同一项外部契约:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.md)会先作用于不透明字节,然后才由任一平台执行发布。
|
||||
|
||||
Windows 测试会在原生 Windows 上执行真实的 Win32 发布路径。断电行为属于 API 契约属性,单元测试无法证明;可测试的不变量包括:Windows 物化不会调用目录 fsync、最终路径冲突会失败、达到最大长度的目标路径组件仍可物化、临时日志在发布前已经执行 fsync,并且生成的日志可以正常加载。
|
||||
|
||||
两个平台的追加和修复仍使用普通文件句柄 fsync。追加失败后,系统会关闭仅追加句柄,以读写模式重新打开日志,将文件截断到追加前的大小,并对回滚结果执行 fsync,因为 Windows 不允许在仅追加句柄上调用 `ftruncate`。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-06-tool-result-retention-library.md: 5e42660360e5a23b419c75b9c8006bec459bc322
|
||||
2026-07-06-tool-result-retention-library.zh.md: 6e824667f17b361efb57b173c44f489da2cab3b3
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-06-tool-result-retention-library.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Several model-facing tools already bound the amount of context they return, but each one owns a different local mechanism and vocabulary: bash keeps a tail plus spill files, web search caps source lists, web fetch caps body content, and `glob` / `grep` discovery needs an inline first page while keeping exact omission metadata for the full result set. A single `truncate(text)` helper cannot cover those cases: item tools need item counts and grouping outside the primitive, while text tools need byte budgets and UTF-8-safe head/tail cuts.
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
# Agent Note: 工具结果保留库
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-06-tool-result-retention-library.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
多个面向模型的工具已经限制其返回的上下文量,但每个工具都拥有不同的局部机制和词汇:bash 保留尾部并提供落盘文件;web search 限制来源列表;web fetch 限制正文内容;`glob`/`grep` 发现工具需要在行内提供第一页,同时为完整结果集保留精确的省略元数据。单一的 `truncate(text)` 辅助函数无法覆盖这些情况:条目型工具需要条目计数,并在原语之外分组;文本型工具则需要字节预算和 UTF-8 安全的首尾裁切。
|
||||
|
||||
这些工具需要共享的抽象是**保留**,而不是通用集合。调用方向一个有界对象输入条目或文本分片,稍后取得保留内容与精确的省略元数据。工具专用代码仍负责业务语义:文件分组、行号、退出码、提供方错误状态、落盘文件和面向模型的说明。公共库只负责一个机械问题:「保留了什么,又省略了什么?」
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-retention` 位于 `packages/util/` 下,与 `dsh-brand` 和 `dsh-timeout` 同级,负责有界的模型可见输出。它是一组纯类与函数构成的库,**不是** Cordis 服务或插件:不接收 `ctx`、不注册任何内容、不持有跨调用状态,也不发出事件。各工具包(package)需要限制输出时直接导入它。
|
||||
|
||||
该库包含两个相互独立的 retainer:
|
||||
|
||||
- `ItemRetainer<T>` 处理有序逻辑单元,例如路径、grep 匹配项或搜索来源。v1 只支持 `head` 保留,同时维持 retainer 形态,以便未来加入其他保留策略。
|
||||
- `TextRetainer` 处理面向字节的文本流,例如 bash stdout/stderr 或 web 响应正文。它支持 `head`、`tail` 和 `headTail` 保留,并在 `finish()` 时维持 UTF-8 边界。
|
||||
|
||||
两个 retainer 都会返回一个小型 `PushDecision`;每次调用 `push()` 后,调用方都能得知该单元/分片是否完整保留,以及累积结果此时是否已被截断。因为调用方会继续输入每一个已观察到的条目/分片,所以省略计数是精确的。
|
||||
|
||||
```ts ignore-check
|
||||
/**
|
||||
* How much content the retainer omitted.
|
||||
*
|
||||
* `unknown` is reserved for callers that omit without a count; the retainers
|
||||
* themselves return `none` or `exact`.
|
||||
*/
|
||||
type Omitted =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'exact'; count: number }
|
||||
| { kind: 'unknown' }
|
||||
|
||||
interface PushDecision {
|
||||
kept: boolean
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Final result for ordered logical units.
|
||||
*/
|
||||
interface RetainedItems<T> {
|
||||
items: T[]
|
||||
truncated: boolean
|
||||
seen: number
|
||||
kept: number
|
||||
omitted: Omitted
|
||||
}
|
||||
|
||||
/**
|
||||
* Final result for text streams.
|
||||
*
|
||||
* The returned `text` is safe to send to a formatter; the retainer does not add
|
||||
* tool-specific headers, exit markers, XML tags, or recovery instructions.
|
||||
*/
|
||||
interface RetainedText {
|
||||
text: string
|
||||
truncated: boolean
|
||||
omittedBytes: Omitted
|
||||
}
|
||||
```
|
||||
|
||||
### 策略
|
||||
|
||||
条目保留支持头部窗口。文本保留支持头部、尾部与首尾字节窗口。
|
||||
|
||||
```ts ignore-check
|
||||
type ItemRetentionStrategy =
|
||||
| {
|
||||
/** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */
|
||||
kind: 'head'
|
||||
maxItems: number
|
||||
}
|
||||
|
||||
type TextRetentionStrategy =
|
||||
| {
|
||||
/** Keep the first `maxBytes` bytes. */
|
||||
kind: 'head'
|
||||
maxBytes: number
|
||||
}
|
||||
| {
|
||||
/** Keep the final `maxBytes` bytes. Requires reading to the end. */
|
||||
kind: 'tail'
|
||||
maxBytes: number
|
||||
}
|
||||
| {
|
||||
/** Keep a stable prefix and suffix, omitting the middle. Requires reading to the end. */
|
||||
kind: 'headTail'
|
||||
headBytes: number
|
||||
tailBytes: number
|
||||
}
|
||||
```
|
||||
|
||||
### 工具映射
|
||||
|
||||
`read` 被有意排除在 v1 保留库之外。它的 `read-render` 辅助函数拥有文件专用的分页契约:`offset`/`limit`、行号、`totalLines`、offset 越界错误、逐行预览截断,以及能够在窗口中途停止扫描的所选输出字节上限。这是行窗口渲染器,不是通用保留原语。它未来可以共享中性的提示辅助函数,但不应把已经选定的窗口再传入 `ItemRetainer`。
|
||||
|
||||
下文的 `FsGlobEntry` 与 `FlatGrepMatch` 是预期由发现工具使用的条目形态,不是现有保留库的导出。`FsGlobEntry` 是一个由后端派生的路径;`FlatGrepMatch` 是后端将保留匹配项按文件分组之前的一条未分组 grep 匹配。
|
||||
|
||||
`glob` 收集完整的排序路径列表后,使用 `ItemRetainer<FsGlobEntry>`,并将其配置为 `{ kind: 'head', maxItems: globMaxResults }`。工具在行内保留第一页,并可以通过落盘 seam 保存完整列表。路径映射、跳过的候选项与 `incomplete` 均位于 retainer 之外。
|
||||
|
||||
`grep` 在分组前使用 `ItemRetainer<FlatGrepMatch>`,并将其配置为 `{ kind: 'head', maxItems: grepMaxMatches }`。执行器解析 ripgrep 输出、映射路径、应用逐行预览截断,并输入扁平匹配项。调用 `finish()` 后,工具按文件对保留的匹配项分组;如果行内结果达到上限,还可以通过落盘 seam 保存完整匹配列表。分组不属于 retainer,因为上限针对匹配总数,而不是文件数;逐匹配项的预览截断和 `incomplete` 也与结果级保留相互独立。
|
||||
|
||||
`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责落盘文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.md)相互独立。
|
||||
|
||||
`web_fetch` 可以使用 `TextRetainer`,配置为 `head` 或 `headTail`;如果提供方必须在内部读取和解码,也可以保留由提供方负责的正文上限。无论采用哪种方式,fetch 结果中的 `truncated` 仍是提供方/工具事实,该库只提供保留文本与省略元数据。
|
||||
|
||||
`web_search` 可以使用 `ItemRetainer<WebSearchSource>`,配置为 `head`。当前提供方通常返回数组,所以这属于事后处理,但仍能统一提示信息。
|
||||
|
||||
### 提示
|
||||
|
||||
该库公开一个中性的提示结构和一个小型格式化钩子,但面向用户的措辞由工具提供。grep 页脚会提示「缩小 pattern、path 或 include」;web fetch 页脚会提示「获取更具体的 URL 或章节」;bash 则可以指向落盘文件。retainer 无法得知这些恢复操作。
|
||||
|
||||
```ts ignore-check
|
||||
interface RetentionNotice {
|
||||
scope: string
|
||||
strategy: 'head' | 'tail' | 'headTail'
|
||||
unit: 'items' | 'bytes' | 'chars' | 'lines'
|
||||
limit: number | { head: number; tail: number }
|
||||
kept: number
|
||||
omitted: Omitted
|
||||
}
|
||||
|
||||
const formatGrepNotice = (notice: RetentionNotice): string =>
|
||||
formatRetentionNotice(
|
||||
notice,
|
||||
({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
|
||||
)
|
||||
```
|
||||
|
||||
格式化钩子刻意保持精简:工具把 `RetentionNotice` 转换为自己的页脚文本。辅助函数可以统一省略措辞,但不负责恢复指引。
|
||||
|
||||
`truncated` 表示 retainer 因预算省略了原本可用的内容,不表示上游结果不完整。工具会为权限失败、跳过的二进制文件、提供方局部失败、不可读候选项、无效 UTF-8,以及其他任何「无法检查」状况保留独立字段。
|
||||
|
||||
## 影响
|
||||
|
||||
**已交付内容。** `@deepseek-ai/dsh-retention` 导出 `ItemRetainer`、`TextRetainer`、结果类型(`RetainedItems`、`RetainedText`)、策略类型(`ItemRetentionStrategy`、`TextRetentionStrategy`)、`Omitted`、`PushDecision`、`RetentionNotice`,以及中性的提示辅助函数 `describeOmitted`/`formatRetentionNotice`,且不依赖 Cordis 或任何工具包。单元测试覆盖具有精确省略计数的条目头部保留、文本头部保留、文本尾部保留、首尾字节保留、零预算、UTF-8 边界处理(2、3、4 字节码位,以及每个裁切位置上的无效起始字节)和未知省略量的措辞。
|
||||
|
||||
**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/retention/README.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口契约(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。
|
||||
|
||||
**该库维持的边界。** `truncated` 表示 retainer 因预算省略了原本可用的内容,绝不表示上游不完整。工具专用状态,包括 `incomplete`、权限失败、提供方局部失败、跳过二进制文件、bash 落盘路径恢复和无效 UTF-8,均留在工具领域字段中、位于 retainer 之外。未来改动迁移某项工具时,该包的 README 与测试必须证明,除了有意改变的提示措辞外,模型可见的结果文本没有变化。
|
||||
|
||||
**接受的取舍。** v1 接口刻意只支持条目的 `head` 保留,以及文本的 `head`/`tail`/`headTail` 保留;窗口、分组预算、感知排序的上限和上游停止控制,要等第二个消费方证明需求后再引入。文本保留按字节计数,以保障进程/正文安全;字符级和行级预览预算继续由具体工具负责。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只进行事后 `truncate(text)`。** 不予采纳:它适合 Codex 的历史/工具输出截断场景,却会丢失条目计数、分组边界、UTF-8 安全的字节窗口与精确省略元数据。
|
||||
|
||||
**使用一个带可插拔回调的通用 `Collector<T>`。** v1 不予采纳,因为它会掩盖两种重要的资源模式。逻辑条目保留按条目计数;文本保留按字节计数并维持 UTF-8 边界。独立的 `ItemRetainer` 与 `TextRetainer` 名称明确表达这种差异,同时保持 API 精简。
|
||||
|
||||
**把 `read` 窗口交给 `ItemRetainer`。** v1 不予采纳:`read` 是当前唯一的窗口消费方,其语义属于文件分页,而不是通用保留。一个 `Omitted` 计数无法表示行窗口两侧,而且 `read` 还携带 `totalLines`、offset 范围错误、逐行预览截断和针对所选输出的字节上限。让 `read-render` 由工具所有,可以避免共享库围绕一项特例膨胀。
|
||||
|
||||
**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer 使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.md)可以保留完整的已采集结果。
|
||||
|
||||
**在每个面向模型的工具 schema 中公开上限。** 不作为默认方案:Claude Code 的 grep 公开 `head_limit`/`offset`,但本 harness 会把常规预算保留为部署配置,除非模型确实需要控制分页。未来可以为具体工具增加类似 read 的续传字段;它不属于共享保留原语。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-08-tool-output-spill-files.md: 7c0ca90452645d251559be25108d12883210d00e
|
||||
2026-07-08-tool-output-spill-files.zh.md: 917d710eb8650e2797287578edd1b0d62813bbd3
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-08-tool-output-spill-files.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Tool outputs need bounded model-facing previews, but some oversized results are still useful later. A fetched page body or a verbose tool response should not consume the next model request in full, but the model should be able to inspect the complete formatted result later with existing file-reading tools.
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Agent Note: 工具输出落盘策略
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-08-tool-output-spill-files.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
工具输出需要有界的模型可见预览,但部分超大结果仍可能在之后有用。抓取的页面正文或冗长的工具响应不应完整占用下一次模型请求,但模型应能使用现有文件读取工具,在之后查看经过格式化的完整结果。
|
||||
|
||||
这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时落盘文件;普通文本工具结果则仍以内联形式返回,除非工具自行临时实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。
|
||||
|
||||
其形态与超时策略设计一致:工具作者声明规范值与 Native renderer,由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前落盘;由工具负责的展示落盘可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出契约](2026-07-20-canonical-tool-output-contract.md)规定了这项区分。
|
||||
|
||||
## 决策
|
||||
|
||||
在新的 `packages/spill/` 分组下增加一层轻量落盘存储 seam 和一个默认落盘策略插件:
|
||||
|
||||
| 包(package) | 角色 |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-spill` | 接口:`ctx.spillStore`、词汇类型,不包含存储实现。 |
|
||||
| `@deepseek-ai/dsh-spill-local` | 本地后端:在宿主文件系统中提供私有、会话作用域的文件存储。 |
|
||||
| `@deepseek-ai/dsh-spill-policy` | 工具结果策略插件:包装分发后的最终文本结果,并以保留预览和落盘定位符替换超大结果。 |
|
||||
|
||||
系统不增加专用的面向模型消费方包。消费方是现有 `ctx.tools` 执行流水线:`dsh-spill-policy` 通过 `tools/post-execute` waterfall(瀑布式事件)使用最终工具结果,模型则按照后端随定位符返回的检索提示读取内容。
|
||||
|
||||
### 落盘 seam
|
||||
|
||||
存储 seam 保持最小化:保存文本,并返回定位符与检索提示。
|
||||
|
||||
```ts ignore-check
|
||||
interface SpillStore {
|
||||
saveText(input: SaveTextSpill): Promise<SpillRef>
|
||||
}
|
||||
|
||||
interface SpillSource {
|
||||
toolName: string
|
||||
callId: CallId
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SaveTextSpill {
|
||||
owner: { sessionId: SessionId }
|
||||
source: SpillSource
|
||||
suggestedName: string
|
||||
content: string
|
||||
}
|
||||
|
||||
type SpillLocator = Branded<'SpillLocator'>
|
||||
|
||||
interface SpillRef {
|
||||
locator: SpillLocator
|
||||
bytes: number
|
||||
retrievalHint: string
|
||||
}
|
||||
```
|
||||
|
||||
`SpillLocator` 是一个[品牌化的](../../../../packages/util/brand)模型可见句柄,由后端返回。本地后端将其渲染为文件系统路径;远程或数据库后端可以渲染 URI、键或命令 token。消费方把它视为不透明值,并使用 `retrievalHint` 渲染,而不是假定 `read` 始终是正确的检索机制。`SpillOwner.sessionId` 是保存时的存储命名空间:fork 后的会话会从种子日志继承已有的落盘定位符,无需复制它们或重新取得所有权;fork 后的新落盘使用子会话 id。保留期清理可以连同其他旧会话产物一起使旧定位符失效;落盘 seam 不定义逐会话的清理策略。
|
||||
|
||||
`dsh-spill-local` 只负责存储细节:选择会话作用域的目录、安全名称、防止路径遍历、执行写入,以及返回 `{ locator, bytes, retrievalHint }`。它不负责保留策略、工具结果替换、搜索或文件检查。文件写入 `<root>/session-<hash>/<random>-<safeName>`:`root` 是配置路径,或延迟创建的私有(0700)进程级临时目录;会话子目录是 `sha256(sessionId)` 的短前缀;叶节点由随机十六进制前缀与调用方的 `suggestedName` 组成,后者会被清理成单一路径段(与 JSONL 后端的 `encodeSegment` 一致)。系统使用 `open(path, 'wx', 0o600)` 写入,确保独占且仅所有者可访问,因此预先植入的符号链接无法重定向写入。定位符就是该路径,检索提示则告知模型可以在该路径上使用 `read` 或 `grep`。
|
||||
|
||||
### 落盘策略
|
||||
|
||||
`dsh-spill-policy` 是一个 `tools/post-execute` 结果转换器,只提供一个配置项:
|
||||
|
||||
```ts ignore-check
|
||||
interface Config {
|
||||
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
|
||||
maxInlineBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
省略 `maxInlineBytes` 时,插件不会注册任何内容,是真正的无操作。设置该值后,它会对最终的纯文本工具结果应用默认策略:
|
||||
|
||||
1. 让工具正常运行,通过 `next()` 委托,使下游监听器先结算结果。
|
||||
2. 仅当已接受的最终 `ContentBlock[]` 全部是纯文本时,才将其展平;含任何非文本块的结果保持不变。
|
||||
3. 如果 UTF-8 字节大小不超过 `maxInlineBytes`,保持不变。
|
||||
4. 如果超出上限,使用完整的最终文本调用 `ctx.spillStore.saveText()`。
|
||||
5. 把模型可见结果替换为保留的首尾预览和落盘引用。
|
||||
|
||||
预览属于策略所有的实现默认值:以 `maxInlineBytes` 为上限,使用保留库的 `TextRetainer` 进行首尾分割。只有第二种部署证明有此需求后,未来配置才会公开预览大小。
|
||||
|
||||
替换文本刻意保持通用,因为策略只知道最终格式化的工具结果,不了解工具的内部资源:
|
||||
|
||||
```text
|
||||
<retained preview>
|
||||
|
||||
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
如果 `ctx.spillStore.saveText()` 失败(权限、ENOSPC、后端不可用),或调用没有会话所有者,或未加载后端,插件会记录原因并原样返回结果。落盘失败绝不会把成功的工具调用变为 `isError` 结果,也不会隐藏内联结果。
|
||||
|
||||
策略跳过 `read`,以避免形成 `read -> spill file -> read again` 循环。额外的选择退出配置要等确实出现第二个有此需求的工具后再引入。
|
||||
|
||||
## 示例:web_fetch
|
||||
|
||||
`web_fetch` 是首个示例,因为它天然会返回较大的文本结果,而且无需工具专用的落盘代码。该工具本身无需特殊处理:
|
||||
|
||||
```ts ignore-check
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'web_fetch',
|
||||
output: {
|
||||
schema: WEB_FETCH_RESULT_SCHEMA,
|
||||
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value) }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const result = await ctx.web.fetch({ url: args.url }, exec.signal ? { signal: exec.signal } : undefined)
|
||||
return result
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
配置 `dsh-spill-policy` 后,格式化后的大型 fetch 结果会自动保留并落盘。部署通过把提供方资源上限设得高于策略上限来展示此行为:
|
||||
|
||||
```yaml
|
||||
- id: web-fetch-local
|
||||
name: '@deepseek-ai/dsh-web-fetch-local'
|
||||
config:
|
||||
maxBodyChars: 500000
|
||||
|
||||
- id: spill-local
|
||||
name: '@deepseek-ai/dsh-spill-local'
|
||||
|
||||
- id: spill-policy
|
||||
name: '@deepseek-ai/dsh-spill-policy'
|
||||
config:
|
||||
maxInlineBytes: 50000
|
||||
```
|
||||
|
||||
这项分离很重要。`web-fetch-local` 仍负责资源上限(`maxResponseBytes`、`maxBodyChars`),用来保护网络、内存和解码工作。`spill-policy` 只负责结果已经存在后针对模型上下文的上限。如果提供方已经返回 `truncated: true`,落盘文件包含的是工具返回的完整格式化结果,而不是原始网页全文;策略不会做出其他承诺。
|
||||
|
||||
## 与保留和提前落盘的关系
|
||||
|
||||
保留与落盘存储相互独立:
|
||||
|
||||
- `@deepseek-ai/dsh-retention` 负责预览机制(`TextRetainer`、`ItemRetainer` 和省略元数据)。
|
||||
- `@deepseek-ai/dsh-spill` 负责保存最终文本,并返回定位符与检索提示。
|
||||
- `@deepseek-ai/dsh-spill-policy` 在工具流水线中应用默认的最终结果策略,将前两者组合起来。
|
||||
|
||||
最终结果策略不能取代由工具负责的提前落盘。部分有用内容并不存在于最终 `ToolExecutionResult.content` 中:
|
||||
|
||||
- `bash` 的最终输出已经是尾部内容加临时落盘路径;完整的 stdout/stderr 流位于执行器文件中。
|
||||
- `subagent` 的最终输出是子 agent(智能体)的最终回答,而不是子 agent 的执行轨迹。
|
||||
- 未来的工具可能生成从未出现在最终 `ToolExecutionResult.content` 中的运行时产物。
|
||||
|
||||
这些场景可以在后续工作中直接使用 `ctx.spillStore`,不属于首个示例的范围。
|
||||
|
||||
## 非目标
|
||||
|
||||
- v1 不增加面向模型的 `artifact_read` 或 `artifact_search` 工具。
|
||||
- v1 不增加逐工具的保留配置。
|
||||
- 不增加面向模型的超时/截断参数。
|
||||
- 不把 `read` 输出迁移到落盘文件。
|
||||
- 不取代 `web-fetch-local.maxBodyChars` 等提供方/资源上限。
|
||||
- 第一版不统一 bash 临时文件,也不采集 subagent 执行轨迹。
|
||||
|
||||
## 延后事项
|
||||
|
||||
- 用于现有执行器落盘文件的 `saveFile()`/`linkOrCopy`,这是统一 bash 行为所必需的。
|
||||
- 由工具负责的 subagent 执行轨迹落盘(`await run.result`,在 `run.dispose()` 前读取进程内子会话,保存 JSONL)。
|
||||
- 如果内置的 `read` 跳过规则不足,再增加逐工具退出或逐工具策略声明。
|
||||
- 面向 ACP(Agent Client Protocol)或远程环境的远程/数据库存储后端,因为本地路径在这些环境中没有意义。
|
||||
- 旧落盘文件的清理和保留策略,很可能与会话清理绑定。
|
||||
|
||||
## 测试
|
||||
|
||||
- `dsh-spill` 单元测试锁定 seam 契约:注册为 `ctx.spillStore`、每个上下文只允许一种实现,并在 dispose(资源释放)时释放。
|
||||
- `dsh-spill-local` 单元测试覆盖 `saveText`、`encodeSegment` 清理(分隔符/波浪号/完整路径段的点/空值)、会话哈希目录、仅所有者权限、每次保存生成不同路径、配置根目录/私有根目录,以及存储失败时的拒绝。
|
||||
- `dsh-spill-policy` 单元测试通过 `ctx.tools.execute` 驱动真实工具:禁用模式下无操作、替换超大文本、小结果/非文本结果保持不变、跳过 `read`、尽力回退(保存失败/无后端/无所有者),以及下游组合(限制已替换结果、保留 `additionalContexts`)。
|
||||
- `dsh-tool-web` 集成测试驱动 `web_fetch`,其实际执行路径经过 `ctx.tools.execute`,并使用真实的 `spill-local` 后端与策略;测试证明只有刻意加入的落盘提示会改变模型可见文本,而落盘文件保存完整的格式化结果。
|
||||
- `tui-agent` 示例加载 `spill-local` 与 `spill-policy`,因此其无密钥 Loader/PTY 冒烟测试会执行真实加载路径(namespace-plugin 导出形态与 `inject`)。
|
||||
|
||||
## 影响
|
||||
|
||||
默认策略只能看见最终格式化文本。它无法保留已经由提供方限制的内部内容,也无法保留从未成为结果一部分的运行时产物。第一版聚焦最终结果落盘而不是提前落盘,因此可以接受这一限制;由工具负责的提前落盘仍属于后续工作。
|
||||
|
||||
本地后端返回真实路径,使 v1 保持简单并符合已经验证的 agent 工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。
|
||||
|
||||
本地后端的价值取决于现有 `read`/`grep` 工具能否检查返回的本地路径,即使落盘目录位于会话 cwd 之外。目前这一条件成立,因为文件系统策略会记录观察结果并设置写保护,但不会把读取限制在工作区内。未来的工作区限制策略必须显式允许本地落盘路径,或改用检索提示指向受支持读取器的非文件落盘后端。
|
||||
|
||||
**快照缺口。** 目前没有 ACP 快照场景覆盖 transcript(文本记录)可见的 `web_fetch` 落盘提示。ACP 快照 harness 在无密钥环境中回放,无法访问实时 web,而 `web_fetch` 落盘需要一个真实的超上限 HTTP 正文;确定性场景需要一个预置的 loopback fetch 目标,但当前回放树尚未接线(示例根本没有加载 `tool-web`)。该行为改由 `dsh-tool-web` 针对 loopback server 的集成测试覆盖。弥补该缺口属于后续工作:把 `tool-web` 和预置 fetch 目标接入 ACP 示例,然后录制 `web-fetch-spill` 场景。
|
||||
|
||||
如果策略开始负责工具专用语义,就会膨胀得过大。它必须保持狭窄:只处理纯文本最终结果。由工具负责的提前落盘仍留作未来工作。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**要求每个工具通过保留声明选择加入。** v1 不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。
|
||||
|
||||
**把 `tool-results` 建成宽泛的工具结果平台。** 不予采纳:宽泛的包名会诱使系统把保留策略、结果替换、预览措辞、搜索和提前落盘合并进一个 seam。可共享的存储部分更小:保存文本,并返回定位符与检索提示。
|
||||
|
||||
**使用 `ctx.fs.writeText` 或面向模型的 `write` 工具。** 不予采纳:工作区文件系统写入带有项目文件语义、写入/编辑策略、观察状态和面向用户的副作用。落盘文件是运行时产物,不是由模型编写的工作区改动。现有 `read` 工具之后可以检查它们,但创建操作属于运行时落盘 seam。
|
||||
|
||||
**让 `web-fetch-local` 不受限地抓取,只依靠 spill-policy。** 不予采纳:spill-policy 在最终工具结果已经存在之后才运行,无法保护网络、内存或解码资源。提供方资源上限仍然必须存在。
|
||||
|
||||
**把保留合并进落盘机制。** 不予采纳:保留与落盘职责不同。`TextRetainer`/`ItemRetainer` 决定保留哪部分预览、又省略了什么;落盘存储只负责保存策略要求的最终文本。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-unified-json-value-schema-dsl.md: 09945c413ffe5924c74076648cdf3da60c3e18c9
|
||||
2026-07-20-unified-json-value-schema-dsl.zh.md: 00a7a199613ea857a7815f1c7794781f143a3896
|
||||
2026-07-20-unified-json-value-schema-dsl.md: 5de3523eab15a91ea32dc09e2e239146fadea6f1
|
||||
2026-07-20-unified-json-value-schema-dsl.zh.md: 321136c31a6aa6c0268150fcde2d97dcdbb0ac58
|
||||
|
||||
@@ -21,6 +21,7 @@ Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep separate parameter and structured-output schema systems:** rejected because every added output construct would require parallel inference, compilation, validation, and code-generation changes with no useful ownership boundary.
|
||||
- **Use Schemastery for tool parameters:** rejected because Schemastery targets validation and transformation through Standard Schema rather than JSON Schema generation. It would add an adapter layer without producing the model-facing wire schema or the shared output vocabulary.
|
||||
- **Adopt full JSON Schema or Ajv:** rejected because the harness must fail on every construct it cannot project into its generated SDK and validators; accepting a larger language would make enforcement and model guidance dishonest.
|
||||
- **Make every object implicitly open or closed:** rejected because either choice hides a consequential author decision. Only the legacy-shaped implicit parameter root and external raw schema retain an intentional default.
|
||||
- **Define `oneOf` as first-match:** rejected because branch ordering would change validation semantics and allow overlapping branches to hide ambiguous values.
|
||||
@@ -32,4 +33,5 @@ Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent
|
||||
- Explicit object openness and type-correct literal constraints make malformed declarations fail during authoring or registration rather than during a later model call.
|
||||
- Bounded type inference retains useful exact types for ordinary declarations and degrades unusually deep tails to `JsonValue`; runtime schema enforcement remains exact at every depth.
|
||||
- Raw tools may still register broader JSON Schema directly, but unified code generation treats unsupported schemas as unknown instead of pretending to enforce them.
|
||||
- Per-property `required: true` remains the tool-author contract, and type-level regression coverage pins required keys as non-optional after the original inference path exposed an optionality bug.
|
||||
- Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, inference, deep nesting across core and dynamic projections, JSON-invisible dynamic keys, and exotic schema arrays.
|
||||
|
||||
@@ -21,6 +21,7 @@ Status: implemented
|
||||
## 备选方案
|
||||
|
||||
- **保留两套独立的参数与结构化输出 schema 系统:**不予采纳。每新增一种输出结构,都必须分别修改类型推导、编译、校验和代码生成,而这种重复并未形成有意义的职责边界。
|
||||
- **使用 Schemastery 处理工具参数:**不予采纳。Schemastery 通过 Standard Schema 面向校验与转换,而不是生成 JSON Schema。采用它会增加一层适配器,却不能产出面向模型的协议 schema 或共享的输出词汇。
|
||||
- **采用完整 JSON Schema 或 Ajv:**不予采纳。harness 必须拒绝所有无法投影到生成 SDK 和校验器中的结构;如果接受更大的语言子集,强制执行能力和模型指引就会与事实不符。
|
||||
- **让所有对象默认开放或默认封闭:**不予采纳。这两种选择都会隐藏一项影响重大的作者决策。只有保持旧有形态的隐式参数根对象和外部原始 schema 才有意保留默认值。
|
||||
- **把 `oneOf` 定义为首个匹配分支:**不予采纳。这样一来,分支顺序会改变校验语义,重叠分支也会掩盖值的歧义。
|
||||
@@ -32,4 +33,5 @@ Status: implemented
|
||||
- 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。
|
||||
- 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为 `JsonValue`;运行时 schema 强制执行在任意深度仍保持精确。
|
||||
- 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。
|
||||
- 每个属性的 `required: true` 仍是工具作者契约;原有推导路径暴露可选性缺陷后,类型级回归覆盖会锁定必填键不得为可选。
|
||||
- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导、核心投影和动态投影中的深层嵌套、动态注册中 JSON 不可见的键,以及非普通 schema 数组。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-22-slot-type-chain-implementation.md: 65b4ebb475fe34d71d8d3a08878b40103b3c95bd
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 4c55171ca0118782568e17f349f83d6cf9211617
|
||||
2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099
|
||||
|
||||
@@ -42,7 +42,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child
|
||||
|
||||
| Share | Type | Source of truth | Contents |
|
||||
|---|---|---|---|
|
||||
| runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions` |
|
||||
| runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` |
|
||||
| child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` |
|
||||
| store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
|
||||
| business | `I` | inject return type | plain data + callbacks (hooks banned) |
|
||||
@@ -84,7 +84,7 @@ An inject factory takes what its declarations earn it — `sessionId` for sessio
|
||||
|
||||
### Data-boundary discipline
|
||||
|
||||
Hooks are framework-made only: `useSession`, `useSessions`, `useStore`, `renderSlot` are the four seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
|
||||
Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
|
||||
|
||||
### Tree context and the renderer seam
|
||||
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ ctx.slots.register({
|
||||
|
||||
| 份额 | 类型 | 真源 | 内容 |
|
||||
|---|---|---|---|
|
||||
| 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions` |
|
||||
| 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` |
|
||||
| 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` |
|
||||
| store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
|
||||
| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) |
|
||||
@@ -84,7 +84,7 @@ inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,
|
||||
|
||||
### 数据界线纪律
|
||||
|
||||
hook 只许框架造:`useSession`、`useSessions`、`useStore`、`renderSlot` 是仅有的四席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
|
||||
hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 是仅有的五席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
|
||||
|
||||
### 树上语境与渲染器安装缝
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-23-unified-session-query-service.md: 0a466e1c36ff1796c858666b0eb36bbd0f480bb0
|
||||
2026-07-23-unified-session-query-service.zh.md: 448122b8e6951058b9f633cd56112b0391e1912e
|
||||
2026-07-23-unified-session-query-service.md: 676a42017ca42f9e649f6529f84787e7162faac0
|
||||
2026-07-23-unified-session-query-service.zh.md: d4449a415840d61cbb10f88def1062a13e556749
|
||||
|
||||
@@ -16,6 +16,8 @@ The interface package already owns the shared record, filter, trace, search-requ
|
||||
|
||||
`SessionQuerySqlite` extends that service and is the sole concrete backend. One mounted instance therefore exposes every operation through `ctx.sessionQuery`; its inherited exact operations use the shared corpus implementation, while its SQLite-owned lifecycle observes sources, reconciles the derived FTS index, ranks matches, and owns cursor generations. The interface package has no standalone concrete plugin, search-provider registry, or second context key.
|
||||
|
||||
SQLite reconciliation is one quiescent serialized state machine. It passes the caller's exact abort signal into durable snapshot listing and inspection, awaits each started backend operation itself, and checks cancellation after every await and before starting the next source or index operation. Cancellation therefore cannot release the serializer while an ignored or cooperative backend call is still cleaning up, and it cannot start a subsequent listing, inspection, reconciliation, or query after the signal is observed.
|
||||
|
||||
Backend configuration includes the inherited `readWindowMax` setting alongside its own index path, journal mode, page limits, and snippet limit. First-party apps that need session queries mount the SQLite backend and place its disposable index beside their configured persistence root.
|
||||
|
||||
This service topology supersedes the separate-key portion of the [exact query decision](../feature/2026-07-10-session-query-service.md) and [SQLite search decision](../feature/2026-07-10-sqlite-session-query-provider.md); their corpus, query, tokenizer, reconciliation, and safety decisions remain in force.
|
||||
@@ -32,4 +34,6 @@ Consumers inject one service and can combine exact and full-text operations with
|
||||
|
||||
The unified object deliberately retains two internal observation strategies: exact operations read authoritative live/persisted sources per call, while full-text operations reconcile a disposable index. Sharing the context key does not make the derived index authoritative or couple exact-read availability to an FTS query.
|
||||
|
||||
Queued cancellation remains prompt. Cancellation during active asynchronous source observation waits for that started operation to settle, which makes rejection a quiescence boundary and preserves single-file execution for a following search. Synchronous SQLite statements remain non-preemptible and are bracketed by signal checks.
|
||||
|
||||
Unit coverage pins inherited and abstract behavior on one key, SQLite coverage exercises both operation families on the concrete backend, and the real Loader path verifies that one exported plugin registers the combined service.
|
||||
|
||||
@@ -16,6 +16,8 @@ Status: implemented
|
||||
|
||||
`SessionQuerySqlite` 扩展该服务,并且是唯一的具体后端。因此,一个挂载实例便可通过 `ctx.sessionQuery` 暴露全部操作;其继承的精确操作使用共享的语料库实现,而由 SQLite 管理的生命周期负责观察数据源、对齐派生 FTS 索引、对匹配项排序并管理游标代际。接口包不提供独立的具体插件、搜索提供方注册表或第二个上下文键。
|
||||
|
||||
SQLite 的对齐过程是一个具备静止性保证的串行状态机。它将调用方的原始中止信号传给持久化快照列表与检查操作,直接等待每个已经启动的后端操作,并在每次等待后以及启动下一个数据源或索引操作前检查是否已取消。因此,即使后端忽略取消或正在配合清理,串行器也不会提前释放;观察到中止信号后,也不会再启动后续的列表、检查、对齐或查询操作。
|
||||
|
||||
后端配置除了自身的索引路径、日志模式、分页限制与文本片段长度上限外,还包含继承的 `readWindowMax` 设置。需要会话查询的第一方应用挂载 SQLite 后端,并将其可丢弃索引放在已配置的持久化根目录旁。
|
||||
|
||||
这一服务拓扑取代了[精确查询决策](../feature/2026-07-10-session-query-service.md)和 [SQLite 搜索决策](../feature/2026-07-10-sqlite-session-query-provider.md)中关于分离上下文键的部分;其中关于语料库、查询、分词器、对齐与安全性的决策仍然有效。
|
||||
@@ -32,4 +34,6 @@ Status: implemented
|
||||
|
||||
统一后的对象有意保留两种内部观察策略:精确操作在每次调用时读取权威的实时源或持久化源,全文操作则使可丢弃索引与数据源对齐。共用上下文键不会让派生索引成为权威来源,也不会使精确读取的可用性依赖 FTS 查询。
|
||||
|
||||
排队阶段的取消仍会及时生效。在异步数据源观察已经开始后取消时,调用方会等待该操作完成清理后才收到拒绝;因此拒绝本身构成静止边界,并保证后续搜索仍按单一串行流程执行。同步 SQLite 语句无法在执行中被抢占,服务会在其前后检查中止信号。
|
||||
|
||||
单元测试在同一个键上同时固定继承实现与抽象方法的契约,SQLite 测试在具体后端上覆盖两类操作,真实 Loader 路径则验证单个导出的插件能够注册组合后的服务。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-24-dsh-commander-argument-adapter.md: c1124f67a2c5d9fbba1e04c896a1021c370befc9
|
||||
2026-07-24-dsh-commander-argument-adapter.zh.md: be96c354a7dea53446f3c2e35f0e4967265596f5
|
||||
@@ -0,0 +1,53 @@
|
||||
# Agent Note: Parse `dsh` argv through one Commander adapter
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-24-dsh-commander-argument-adapter.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that did not compose and gave no `--help`/`--version`. `bin.ts` dispatched by raw inspection — `argv[0] === 'web'`, then `argv.includes('-p') || argv.includes('--prompt')`, else TUI — which is positional-blind: a prompt flag or a config path in the wrong position could misroute the mode, and `argv.includes('-p')` could not tell a real flag from an incidental token. `headless.ts` and `web.ts` each ran their own `node:util` `parseArgs` with inline host/port validation, and `dsh-app-boot` carried `parseResumeArg`, a ~30-line bespoke scanner reimplementing flag/`=`-form/value/repeat handling for `--resume`. Usage was a single hardcoded `usage: dsh -p "task"` line; there was no version flag and no rendered help.
|
||||
|
||||
## Decision
|
||||
|
||||
Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host?, port?, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`.
|
||||
|
||||
`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev, workspaceRoot)` — none re-reads argv. It is **one Commander program**: the default surface (no subcommand) carries option-only flags — `--config <path>`, `-p/--prompt <task>`, `--resume <id>` — and `web` is a real `program.command('web')` subcommand. The default surface takes no positional argument, which is what lets `web` be a real subcommand without a positional collision, so `dsh --help` lists `web` natively (no hand-pasted command text). The default action and the `web` action set the resolved mode, then bail via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a `--config`/`--resume` alongside it rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume). Commander parses the default-surface options on either side of the `web` token into `program.opts()`; since `web` shares none of them, the `web` action rejects a leaked `--config`/`-p`/`--resume` (`dsh web -p x`, `dsh --config c.yml web`) rather than silently serving and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the adapter assigns no default and does no validation, only `Number`-coercing the port string (the schema wants a number). The `dsh-host-webserver` schemastery `Config` (`host` a `127.0.0.1`/`0.0.0.0` literal union, `port` a natural ≤ 65535) is the single source of both the default (the shipped `apps/cli/cordis.yml` `webserver` row stands when a flag is absent) and validity — `AppCLIEntry` patches an explicit flag straight into that row, so a bad host/port fails loud at the schema on boot, not at parse. `--dev` mounts the client HMR driver and bundle watch, and `--workspace-root <path>` is a plain pass-through to `AppCLIEntry` (the parent directory for name-created workspaces). A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `--version` reads this app's `package.json`.
|
||||
|
||||
`dsh` takes no positional argument. `--config <path>` names an alternate cordis tree to boot instead of the shipped default; it exists only so the demo/test call sites (`demo:cordis`, `demo:code-mode`, the keyless PTY smokes) can point the shipped bin at an example tree. A bare `dsh` boots the shipped tree plus the `~/.dsh/config.yaml` personal overlay; a real user never passes `--config`.
|
||||
|
||||
CLI parsing lives entirely in `apps/cli`. `dsh-app-boot` holds the boot/env/config/personal-overlay helpers and no argv scanner.
|
||||
|
||||
## Session resume through the boot context
|
||||
|
||||
`dsh --resume <id>` is the one way to resume a persisted session, with no environment variable. `runTui` provides the parsed id on the boot context through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a `dsh-app-boot` export, value `'resumeSessionId'`) — and the shipped tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv from the parsed values as `dsh --resume=<id> [--config <path>]`.
|
||||
|
||||
## One terminal front door: `dsh`
|
||||
|
||||
`dsh` is the only terminal entry point; the `dsh-tui-demo` package ships the TUI app bundle plugin the shipped config mounts, and no bin of its own. `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes launch through `apps/cli/src/bin.ts` with `--config <path>`. `dsh`'s TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) is pinned by `apps/cli/tests/built-bin.e2e.ts`, which runs the built `lib/bin.js` under plain Node with piped stdio (`apps/cli/tests` is in the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their own bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide.
|
||||
|
||||
## Package topology
|
||||
|
||||
The argument surface stays inside `apps/cli`, the assembly tier, not a `packages/*` library: it is this one app's routing, not a reusable seam. `dsh-app-boot` shrinks to boot glue with no CLI-parsing responsibility. `commander@^15` is added to `apps/cli/package.json`, matching the SDK bins' pin.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `node:util` `parseArgs` and only unify the dispatch** — rejected: `parseArgs` has no subcommand model, no rendered help, and no version flag, so `web` routing and `--help`/`--version` would stay hand-rolled. The repo already chose Commander for its other CLIs; a second parser idiom for `dsh` alone is the fragmentation this change removes.
|
||||
|
||||
**Keep `parseResumeArg` as a shared helper and feed it Commander's residual args** — rejected: the whole point is to retire the bespoke scanner. Commander parses `--resume` (space and `=` forms, missing-value, position-independence) natively; keeping a parallel hand-written path for the one flag would preserve the duplication the change exists to end.
|
||||
|
||||
**A bare `dsh <config>` positional for the alternate tree** — rejected: a root positional and a real `web` subcommand cannot coexist in one Commander program (the subcommand claims the first positional). A positional would force `web` into a reserved-first-token dispatch to a separate parser and a hand-maintained `web` line in `--help`. Only the demo/test sites ever need to name an alternate tree, so a `--config` flag serves them while leaving the default surface positional-free — `web` is then a normal subcommand in one program with native `--help`.
|
||||
|
||||
**Make the argument surface a `packages/*` seam** — rejected: nothing outside `dsh` consumes it, and capability seams are not split preemptively. The Commander adapter is `apps/cli`'s own concern.
|
||||
|
||||
**Keep `RESUME_SESSION_ID` as the resume bridge** — rejected: with `--resume` parsed into a value the bin already holds, threading it through an environment variable the config re-reads is indirection with no benefit, and it left the demo bin a second, env-only resume path. Providing the id on the boot context is the same channel `boot`'s `prepare` hook already uses for `tuiResumeHost`.
|
||||
|
||||
**Keep the `dsh-tui-demo` bin** — rejected: it duplicated `dsh --config <path>` exactly, and keeping it forced the demo-only `RESUME_SESSION_ID` fallback to stay alive. Its plugin is what the configs actually mount; only the front-door bin was redundant, and `dsh` is the one terminal entry point.
|
||||
|
||||
## Testing
|
||||
|
||||
`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev` and the host/port pass-through), the exit-code behavior for the adapter's fail-loud checks (empty resume/prompt, `--prompt` mixed with a config/`--resume`, unknown option, stray positional), and `--help`/`--version`, captured through a `process.exit` spy. Host/port validity is the webserver schema's job, exercised on boot by the web smoke, not the adapter spec. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots an example tree through `--config`, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command.
|
||||
|
||||
## Consequences
|
||||
|
||||
`dsh` has rendered `--help`/`--version` and consistent fail-loud parse errors, and mode routing does not depend on flag position. Argv parsing lives in one place with one parser idiom shared with the SDK bins, at the cost of a `commander` dependency on `apps/cli` and Commander's parse semantics (its error strings, its `exitOverride` contract) sitting on the CLI's front door. `dsh-app-boot` owns no CLI-parsing surface; a consumer needing `--resume`-style parsing composes Commander. Session resume rides the boot context rather than an environment variable, and `dsh` is the single terminal front door — the `dsh-tui-demo` package is a plugin bundle a config mounts.
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# Agent Note: 通过单个 Commander 适配器解析 `dsh` 的 argv
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-24-dsh-commander-argument-adapter.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`dsh` 的 CLI(命令行界面)入口(`apps/cli`)以三种手写方式解析 argv,这些方式无法组合,也不提供 `--help`/`--version`。`bin.ts` 通过原始检查进行分发:先判断 `argv[0] === 'web'`,再判断 `argv.includes('-p') || argv.includes('--prompt')`,否则走 TUI。这种方式对位置不敏感:位置错误的 prompt 标志或配置路径可能把模式路由错,而 `argv.includes('-p')` 无法区分真正的标志和偶然出现的 token。`headless.ts` 和 `web.ts` 各自运行自己的 `node:util` `parseArgs`,并内联校验 host/port,而 `dsh-app-boot` 携带 `parseResumeArg`——一个约 30 行的定制扫描器,为 `--resume` 重新实现了标志、`=` 形式、取值和重复的处理。用法说明只有一行硬编码的 `usage: dsh -p "task"`;既没有版本标志,也没有渲染出的帮助信息。
|
||||
|
||||
## 决策
|
||||
|
||||
argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配器(SDK bin `create-sdk`、`dsh-scripts` 已经统一采用的同一解析器)。`parseDshArgs(argv, version)` 返回仅包含三种实际模式的判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }` 或 `{ mode: 'web', host?, port?, dev }`。它**不会**将帮助、版本信息或错误建模为数据:这些情况由 Commander 处理,在触发处打印用法或诊断信息并退出。`exitOverride()` 会将每种情况转为抛出的 `CommanderError`,并携带预期退出码(帮助或版本为 0,解析错误或领域错误为 1);唯一一处 `try/catch` 位于 `parseDshArgs` 中,捕获错误后调用 `process.exit`。
|
||||
|
||||
`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev, workspaceRoot)`,都不会再次读取 argv。整个 CLI 由**单个 Commander 程序**实现:默认接口(不使用子命令时)只包含选项标志——`--config <path>`、`-p/--prompt <task>`、`--resume <id>`——而 `web` 是通过 `program.command('web')` 定义的真正子命令。默认接口不接受位置参数,因此 `web` 可以成为真正的子命令且不会发生位置参数冲突,`dsh --help` 也会原生列出 `web`,无需手工拼接命令文本。默认命令和 `web` 子命令的处理函数会设置解析得到的模式,随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式;如果任务为空,或调用中还包含 `--config` 或 `--resume`,它会拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复)。Commander 会将 `web` token 前后的默认接口选项都解析进 `program.opts()`;由于 `web` 不与默认接口共用任何选项,`web` 子命令的处理函数会拒绝误入的 `--config`/`-p`/`--resume`(`dsh web -p x`、`dsh --config c.yml web`),而不是静默启动服务并丢弃这些选项。`dsh web` 的 `--host`/`--port` 是未经校验、直接透传的覆盖值:适配器既不设置默认值,也不执行校验,只使用 `Number` 将端口字符串转换为数字(schema 要求该值为数字)。`dsh-host-webserver` 的 schemastery `Config`(`host` 是 `127.0.0.1`/`0.0.0.0` 字面量联合类型,`port` 是不大于 65535 的自然数)是默认值与有效性的唯一真源:未提供标志时,随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项保持原值;`AppCLIEntry` 将显式标志的值直接写入该配置项,因此无效的 host/port 会在启动时触发 schema 校验并显式失败,而不是在参数解析阶段失败。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视,`--workspace-root <path>` 则是直接透传给 `AppCLIEntry` 的选项(按名称创建 workspace 时使用的父目录)。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`--version` 读取本应用的 `package.json`。
|
||||
|
||||
`dsh` 不接受位置参数。`--config <path>` 指定一份替代 Cordis 配置树,系统启动该配置树而不是随产品提供的默认配置树;该标志仅用于让演示和测试调用点(`demo:cordis`、`demo:code-mode`、无密钥 PTY 冒烟测试)通过随产品提供的 bin 启动一份示例树。直接运行 `dsh` 会启动随产品提供的配置树,并叠加 `~/.dsh/config.yaml` 个人覆盖;实际用户从不传入 `--config`。
|
||||
|
||||
CLI 解析完全位于 `apps/cli` 中。`dsh-app-boot` 提供启动、环境变量、配置和个人覆盖辅助函数,不包含 argv 扫描器。
|
||||
|
||||
## 通过启动上下文恢复会话
|
||||
|
||||
`dsh --resume <id>` 是恢复持久化会话的唯一方式,无需环境变量。`runTui` 通过 `boot` 的 `prepare(ctx)` 钩子,在启动上下文中提供已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的一项导出,值为 `'resumeSessionId'`);随产品提供的 tui-agent/cordis 配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的启动器也能正常运行。`/resume` 原地交接(`process.execve`)根据已解析的值将重新执行时的 argv 构造成 `dsh --resume=<id> [--config <path>]`。
|
||||
|
||||
## 唯一的终端入口:`dsh`
|
||||
|
||||
`dsh` 是唯一的终端入口;`dsh-tui-demo` 包(package)提供 TUI 应用组合插件,随产品提供的配置会挂载该插件,而该包不提供自己的 bin。`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的无密钥 PTY 冒烟测试都通过 `apps/cli/src/bin.ts` 启动,并传入 `--config <path>`。`dsh` 的 TTY 守卫会在启动前拒绝标准输入输出接入管道的调用,并提示自动化场景使用 `dsh -p`;`apps/cli/tests/built-bin.e2e.ts` 锁定了这一行为:该测试将标准输入输出接入管道,并通过普通 Node 运行构建后的 `lib/bin.js`(e2e Vitest 的 include 包含 `apps/cli/tests`)。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。
|
||||
|
||||
## 包拓扑
|
||||
|
||||
参数解析留在 `apps/cli`(组装层)内,而不是 `packages/*` 库中:它是这一个应用自身的路由,而非可复用的 seam。`dsh-app-boot` 收缩为纯粹的 boot 胶水代码,不再承担 CLI 解析职责。`commander@^15` 被加入 `apps/cli/package.json`,与 SDK bin 锁定的版本一致。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留 `node:util` `parseArgs`,只统一分发。** 已否决:`parseArgs` 没有子命令模型、没有渲染出的帮助、也没有版本标志,因此 `web` 路由和 `--help`/`--version` 仍将保持手写。本仓库其他 CLI 已经选择了 Commander;单独为 `dsh` 引入第二套解析器方式,正是这次变更要消除的碎片化。
|
||||
|
||||
**保留 `parseResumeArg` 作为共享辅助函数,并向它喂入 Commander 的残余参数。** 已否决:整件事的核心就是要退役这个定制扫描器。Commander 原生解析 `--resume`(空格和 `=` 形式、缺值、位置无关性);为这一个标志保留一条平行的手写路径,只会保留这次变更要终结的重复。
|
||||
|
||||
**使用裸 `dsh <config>` 位置参数指定替代配置树。** 已否决:根级位置参数与真正的 `web` 子命令无法在同一个 Commander 程序中共存(子命令会占用第一个位置参数)。位置参数会迫使系统把位于首位的 `web` 作为保留 token 分发给另一个解析器,并手工维护一行 `web` 文本,供 `--help` 显示。只有演示和测试调用点需要指定替代配置树,因此 `--config` 标志既能满足这些调用点,又能让默认接口不包含位置参数;这样,`web` 就能在单个程序中成为普通子命令,并由原生 `--help` 展示。
|
||||
|
||||
**把参数解析做成 `packages/*` 的 seam。** 已否决:`dsh` 之外没有任何消费方使用它,而能力 seam 不应被提前拆分。这个 Commander 适配器是 `apps/cli` 自身的事务。
|
||||
|
||||
**保留 `RESUME_SESSION_ID` 作为恢复通道**:不予采纳。`--resume` 已被解析成 bin 当前持有的值;若再通过环境变量传递并由配置重新读取,只会引入无益的间接层,还会使演示 bin 保留第二条仅依赖环境变量的恢复路径。在启动上下文中提供 id,与 `boot` 的 `prepare` 钩子为 `tuiResumeHost` 提供值所采用的是同一通道。
|
||||
|
||||
**保留 `dsh-tui-demo` bin**:不予采纳。它与 `dsh --config <path>` 的功能完全重复;保留它还会迫使演示专用的 `RESUME_SESSION_ID` 回退路径继续存在。配置实际挂载的是该包的插件;冗余的只有作为终端入口的 bin,而 `dsh` 是唯一的终端入口。
|
||||
|
||||
## 测试
|
||||
|
||||
`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev` 和 host/port 透传),并通过 `process.exit` spy 捕获适配器的显式报错检查(恢复 id 或提示词为空、`--prompt` 与配置或 `--resume` 混用、未知选项、多余的位置参数)以及 `--help`/`--version` 的退出码。host/port 的有效性由 webserver schema 负责,并由 web 冒烟测试在启动时验证,不属于适配器测试的覆盖范围。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组通过 `--config` 启动示例树,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。
|
||||
|
||||
## 影响
|
||||
|
||||
`dsh` 会渲染 `--help`/`--version`,并以一致方式显式报告解析错误;模式路由不依赖标志位置。argv 解析集中在一处,并与 SDK bin 共用一套解析器方式,代价是 `apps/cli` 依赖 `commander`,且 Commander 的解析语义(错误字符串和 `exitOverride` 契约)成为 CLI 入口的一部分。`dsh-app-boot` 不提供任何 CLI 解析接口;需要 `--resume` 式解析的消费方通过组合 Commander 来实现。会话恢复通过启动上下文完成,而不使用环境变量;`dsh` 是唯一的终端入口;`dsh-tui-demo` 包是由配置挂载的插件组合包。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-24-project-session-directories.md: 0aa3f513d5a1bb3e44cf33a0ae1eb791ee3a46c2
|
||||
2026-07-24-project-session-directories.zh.md: 3d8d33fa9fddad010ab319ac4e1f873b69b4e1dd
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent Note: Project-grouped session directories
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-24-project-session-directories.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A persistence root may be local to one project, shared by several projects, temporary, or centralized. The hashed cwd buckets kept all deployments functional but made a shared root difficult to navigate because a developer could not recognize a project from its directory name.
|
||||
|
||||
Each JSONL session also occupied one file directly inside the project bucket. That shape had no ownership directory for additional session artifacts such as metadata, attachments, spill files, or coordination state.
|
||||
|
||||
## Decision
|
||||
|
||||
The JSONL backend stores sessions under a readable project key and gives every session its own directory:
|
||||
|
||||
```text
|
||||
<configured-root>/
|
||||
--<normalized-cwd>--/
|
||||
<encoded-session-id>/
|
||||
session.jsonl.zstd
|
||||
```
|
||||
|
||||
Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable name is bounded to keep the component within filesystem limits.
|
||||
|
||||
The project key intentionally has no hash suffix. This follows the common human-readable convention used by coding agents and keeps the normalized project path as the complete directory name. The normalization is lossy: paths such as `/a/b-c` and `/a-b/c`, or long paths with the same retained prefix, share one project directory. Their distinct session ids still select separate session directories; reuse of the same session id remains a storage collision and is rejected.
|
||||
|
||||
Case-insensitive filesystems can also make differently cased project keys refer to one physical directory. Identity validation accepts such an alternate spelling only when filesystem canonicalization resolves the discovered and expected paths to the same transcript. A different canonical path remains corruption, so case aliases do not weaken the same-id collision check on case-sensitive stores.
|
||||
|
||||
The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure.
|
||||
|
||||
The encoded session id names an ownership directory rather than the transcript itself. `SessionPersistence.locate()` continues to return the fixed transcript path, preserving hook `transcript_path` and `DSH_SESSION_JSONL` semantics. Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change.
|
||||
|
||||
Lazy materialization remains tied to the transcript: `create()` performs no filesystem I/O, and the first append creates the project/session directories before collision-safe transcript publication. Empty directories are not listed as sessions. The backend rejects flat `<project>/<id>.jsonl*` artifacts with an explicit layout error; the pre-release format provides no automatic data migration.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep opaque cwd hashes.** This preserved short names but defeated the requested navigation by project path when several projects share a persistence root.
|
||||
|
||||
**Put session files directly in each project directory.** This matched Claude Code and pi's basic file organization but left no session-level ownership boundary for future artifacts.
|
||||
|
||||
**Add a collision-resistant hash suffix.** This distinguishes paths whose normalized forms collide, but makes the directory name more than the normalized project path. The chosen convention accepts lossy project grouping in exchange for the simpler, recognizable name.
|
||||
|
||||
**Mandate a centralized root.** Rejected because storage placement belongs to deployment configuration. Project grouping is useful when roots are shared and harmless when they are not.
|
||||
|
||||
**Load both flat and directory layouts.** Rejected under the pre-release no-compatibility stance. One accepted layout keeps identity checks and discovery deterministic.
|
||||
|
||||
## Consequences
|
||||
|
||||
Shared stores can be navigated by recognizable project names, while local and custom roots keep their existing configuration freedom. Every session has a directory available for future backend-owned artifacts, and existing transcript consumers still receive a file path.
|
||||
|
||||
Project directory names are longer than the former 12-hex cwd hashes. Very long paths show only a bounded prefix. Moving a project usually selects a different directory, but distinct cwd strings that normalize to the same name share one project directory by design.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent Note: 按项目分组的会话目录
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-24-project-session-directories.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
持久化根目录可以只供一个项目使用,也可以由多个项目共享,还可以是临时目录或集中式目录。对 cwd 进行哈希得到的分桶目录能适用于所有这些部署方式,但开发者无法从目录名辨认项目,因此共享根目录难以浏览。
|
||||
|
||||
每个 JSONL 会话也直接以单个文件的形式放在项目分桶目录中。这种布局没有为元数据、附件、溢写文件或协调状态等其他会话产物提供归属目录。
|
||||
|
||||
## 决策
|
||||
|
||||
JSONL 后端按可读的项目键存储会话,并为每个会话提供独立目录:
|
||||
|
||||
```text
|
||||
<configured-root>/
|
||||
--<normalized-cwd>--/
|
||||
<encoded-session-id>/
|
||||
session.jsonl.zstd
|
||||
```
|
||||
|
||||
原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读名称则限制长度,以确保目录项不超过文件系统限制。
|
||||
|
||||
项目键有意不带哈希后缀。这遵循 coding agent(编码智能体)常用的易读约定,使规范化后的项目路径本身就是完整的目录名。规范化过程有损:`/a/b-c` 与 `/a-b/c` 等路径,或者保留前缀相同的长路径,会共用同一个项目目录。不同的会话 id 仍会选择不同的会话目录;复用相同的会话 id 仍构成存储冲突,系统会予以拒绝。
|
||||
|
||||
在不区分大小写的文件系统上,大小写不同的项目键也可能指向同一个物理目录。只有当文件系统路径规范化将发现路径和预期路径解析为同一个 transcript(文本记录)时,身份验证才接受这种拼写变体。规范化后的路径如果不同,仍视为存储损坏,因此大小写别名不会让区分大小写的存储放宽同一 id 的冲突检查。
|
||||
|
||||
根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。
|
||||
|
||||
编码后的会话 id 用于命名归属目录,而不是 transcript 文件本身。`SessionPersistence.locate()` 仍返回固定的 transcript 路径,从而保持钩子 `transcript_path` 和 `DSH_SESSION_JSONL` 的语义不变。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。
|
||||
|
||||
延迟物化仍以 transcript 为界:`create()` 不执行文件系统 I/O,首次追加会先创建项目目录和会话目录,再以无冲突方式发布 transcript。空目录不会被列为会话。后端会显式报告布局错误并拒绝扁平的 `<project>/<id>.jsonl*` 产物;预发布格式不提供自动数据迁移。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留不透明的 cwd 哈希。** 这可以保持目录名简短,但当多个项目共享一个持久化根目录时,无法满足按项目路径浏览的需求。
|
||||
|
||||
**把会话文件直接放入各项目目录。** 这与 Claude Code 和 pi 的基本文件组织一致,但没有为未来产物提供会话级归属边界。
|
||||
|
||||
**添加防冲突的哈希后缀。** 这种方式能区分规范化形式相同的路径,但会使目录名不再只是规范化后的项目路径。所选约定接受有损的项目分组,以换取更简单、易于辨认的名称。
|
||||
|
||||
**强制使用集中式根目录。** 不予采纳,因为存储位置属于部署配置。项目分组在根目录共享时有用,在不共享时也没有负面影响。
|
||||
|
||||
**同时加载扁平布局和目录布局。** 按照预发布阶段不提供兼容性的原则,不予采纳。只接受一种布局,可以让身份检查和发现过程保持确定性。
|
||||
|
||||
## 后果
|
||||
|
||||
共享存储可以通过易于辨认的项目名进行浏览,本地根目录和自定义根目录则继续保有现有的配置自由。每个会话都有一个可供后端未来存放自有产物的目录,而现有 transcript 消费方仍会收到文件路径。
|
||||
|
||||
项目目录名比原先由 12 个十六进制字符组成的 cwd 哈希更长。路径很长时,目录名只显示长度受限的前缀。移动项目通常会选择不同的目录,但按设计,不同的 cwd 字符串如果规范化成相同名称,就会共用同一个项目目录。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: 9e93b828d5f11060aa476396f6981320c33485a5
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 996a5705bd5d00a2163a146ef8210247f512e6fa
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: 377ebd2b3cf9ff1dff81dd3546bb262e0ebde88a
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 403e95fb3088d2164d50710d2a69de5526807764
|
||||
|
||||
+2
-2
@@ -18,14 +18,14 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
|
||||
|
||||
**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config.
|
||||
|
||||
**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from runtime (dependency direction allows it; runtime keeps `bootHost`/`startHost` for headless). `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route.
|
||||
**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from the retired runtime package. `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route.
|
||||
|
||||
**Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted.
|
||||
- Headless still boots through `bootHost` (unchanged this round); its migration, the profile write path, the `$DSH_HOME` profile relocation, and IPC carriers are recorded deferrals in the design ledger.
|
||||
- Headless boots the same composition through the same entry (landed in the stacked follow-up): port 0 is its only surface difference, the model face gains `ask_user_question`/workspace context/model titles per the unification ruling, and `bootHost`/`startHost` retired with the `dsh-host-runtime` package. The profile write path, the `$DSH_HOME` profile relocation, and IPC carriers remain recorded deferrals.
|
||||
- A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
+2
-2
@@ -18,14 +18,14 @@ Status: implemented
|
||||
|
||||
**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。
|
||||
|
||||
**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从 runtime 迁入(依赖方向允许;runtime 保留 `bootHost`/`startHost` 供 headless)。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。
|
||||
**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,config `{provider, model}`,provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 自已退役的 runtime 包迁入。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged`、`/plugins/events` SSE 路由。
|
||||
|
||||
**包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设特设子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读槽(缺槽大声抛)并 provide `ctx.modules`。
|
||||
|
||||
## 后果
|
||||
|
||||
- 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。
|
||||
- headless 本轮仍走 `bootHost`;它的迁移、profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体,均为设计台账中的挂账项。
|
||||
- headless 已在 stacked 后续轮迁入同一组合同一入口:唯一面差异是 port 0,模型面按统一裁决获得 `ask_user_question`/workspace context/模型标题,`bootHost`/`startHost` 随 `dsh-host-runtime` 包退役。profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体仍为挂账项。
|
||||
- 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-19-windows-atomic-write-dacl-preservation.md: 013119508da9be426c417797cf7a0ec14e276814
|
||||
2026-07-19-windows-atomic-write-dacl-preservation.zh.md: 8ae82884c3b80409d07d3bbcfc8c273e8b227dc8
|
||||
2026-07-19-windows-atomic-write-dacl-preservation.md: be9f82174300a7d605c6a6e63878728f08cb37be
|
||||
2026-07-19-windows-atomic-write-dacl-preservation.zh.md: fc6ec5232c992f3a230ee0de89439c869b8b46f9
|
||||
|
||||
+8
-4
@@ -6,13 +6,13 @@ English | [中文](2026-07-19-windows-atomic-write-dacl-preservation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
On Windows, creating the staging directory and temp file under the target's parent and relying only on inherited DACLs is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement.
|
||||
Atomic writes protect POSIX staging directories with `0o700` and temp files with `0o600`, but Windows mode bits expose only a synthetic read-only view of the actual DACL. Creating staging under the target's parent and relying on inheritance is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New files have no prior descriptor to preserve and continue to inherit the destination directory's DACL.
|
||||
`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New Windows files have no prior descriptor to preserve and continue to inherit the destination directory's DACL; their staging directory therefore lives beside the target. POSIX keeps the owner-only staging modes and preserves an existing target mode.
|
||||
|
||||
Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary.
|
||||
Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary. Mode-bit assertions remain POSIX-only; new-file DACL inheritance is an operating-system contract rather than a machine-specific account allowlist.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -22,6 +22,10 @@ Native Windows coverage protects a target DACL, inspects the written staging fil
|
||||
|
||||
**Install an owner-only DACL for every write.** Rejected because it would discard deliberate project sharing. Copying the target DACL preserves the deployment's existing access policy instead of inventing one.
|
||||
|
||||
**Assert inherited accounts with `Get-Acl` or `icacls`.** Rejected because such a test verifies machine policy rather than package behavior, and localized well-known account names make the output unstable across hosts.
|
||||
|
||||
**Skip the existing `chmod` calls on Windows.** Rejected because Node maps these writable modes to benign no-ops; platform guards add branches without changing DACL behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. New-file behavior remains directory-inherited, and POSIX mode behavior is unchanged.
|
||||
Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. A new Windows file inherits broad directory access when the directory is broad by design, while POSIX temp content stays owner-only; a read-only Windows target still fails publication before synthetic mode replay could matter.
|
||||
|
||||
+8
-4
@@ -6,13 +6,13 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
在 Windows 上,在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。
|
||||
原子写入在 POSIX 上以 `0o700` 保护暂存目录、以 `0o600` 保护临时文件,但 Windows mode 位只呈现实际 DACL 的合成只读视图。在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新建文件没有既有描述符需要保留,因此仍继承目标目录的 DACL。
|
||||
`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新的 Windows 文件没有既有描述符需要保留,因此仍继承目标目录的 DACL;其暂存目录也因此位于目标文件旁。POSIX 继续使用仅所有者可访问的暂存 mode,并保留现有目标文件的 mode。
|
||||
|
||||
Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。
|
||||
Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。mode 位断言仍仅适用于 POSIX;新文件的 DACL 继承由操作系统契约规定,不应通过特定机器的账户允许列表来断言。
|
||||
|
||||
## 备选方案
|
||||
|
||||
@@ -22,6 +22,10 @@ Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成
|
||||
|
||||
**每次写入都设置仅所有者可访问的 DACL。** 不予采用,因为这会破坏项目有意设置的共享权限。复制目标文件的 DACL 可以保留部署中已有的访问策略,无需另行创设策略。
|
||||
|
||||
**使用 `Get-Acl` 或 `icacls` 断言继承账户。** 不予采用,因为这类测试验证的是机器策略,而不是包行为;系统内置账户名会本地化,使输出在不同宿主上不稳定。
|
||||
|
||||
**在 Windows 上跳过现有 `chmod` 调用。** 不予采用,因为 Node 会把这些可写 mode 映射为无害的空操作;平台条件判断只会增加分支,不会改变 DACL 行为。
|
||||
|
||||
## 影响
|
||||
|
||||
替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新建文件仍按目录继承,POSIX mode 行为保持不变。
|
||||
替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新的 Windows 文件会在目录按设计开放较宽访问权限时继承该权限,而 POSIX 临时内容仍仅允许所有者访问;只读 Windows 目标文件仍会在发布时失败,早于重放合成 mode 可能产生影响的时点。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-20-jsonl-storage-identity.md: 1ada16791f411a54fbcf9271c7d7963223bbe683
|
||||
2026-07-20-jsonl-storage-identity.zh.md: 8027c51dbf6c7d01463b7851d859a40890bf03e1
|
||||
2026-07-20-jsonl-storage-identity.md: 1079eb700c819951dbb81e99376c0b71e3e84617
|
||||
2026-07-20-jsonl-storage-identity.zh.md: d7ba5c646a7adaaa0ebd60fac7b9c2f030361ff9
|
||||
|
||||
@@ -6,11 +6,11 @@ English | [中文](2026-07-20-jsonl-storage-identity.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
JSONL lookup selects a physical log from the requested session id across cwd buckets, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The bucket scan also needs a defined result when the same encoded id exists in more than one bucket. SQLite does not share this ambiguity because its primary-key query binds metadata and events to the requested id.
|
||||
JSONL lookup selects a physical log from the requested session id across project directories, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The project scan also needs a defined result when the same encoded id exists in more than one project directory. SQLite does not share this ambiguity because its primary-key query binds metadata and events to the requested id.
|
||||
|
||||
## Decision
|
||||
|
||||
`loadStored(id)` is the coordinator's single stored-prefix lookup. The JSONL backend scans every cwd bucket, requires at most one matching encoded filename, parses that file, then validates both `header.id === id` and `selectedPath === logPath(root, header.cwd, header.id)` before returning metadata. `list()` applies the same path validation and rejects duplicate ids across buckets.
|
||||
`loadStored(id)` is the coordinator's single stored-prefix lookup. The JSONL backend scans every project directory, requires at most one matching encoded session directory with a transcript, parses that file, then validates `header.id === id` and that the selected path either equals `logPath(root, header.cwd, header.id)` or filesystem canonicalization resolves both spellings to the same transcript. `list()` applies the same path validation and rejects duplicate ids across project directories.
|
||||
|
||||
The coordinator independently asserts the returned id and compares the stored cwd with a live session's cwd before repair, state publication, or suffix persistence. It keeps a detached copy of validated metadata; JSONL append and repair derive their path from that copy. The `PersistenceBackend<TornMarker>` interface therefore needs neither a scope-specific live lookup nor a storage-locator type.
|
||||
|
||||
@@ -18,7 +18,7 @@ An existing configured JSONL root must be a readable directory when the plugin l
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Flatten storage by session id.** A flat namespace makes duplicate publication collide on one path, but path validation and duplicate rejection close the identity defect without changing the project-grouped cwd layout or its consumers.
|
||||
**Flatten storage by session id.** A flat namespace makes duplicate publication collide on one path, but path validation and duplicate rejection close the identity defect without making the check depend on a flat global namespace.
|
||||
|
||||
**Carry an opaque storage locator through the coordinator.** A locator binds JSONL mutations directly to a selected path, but JSONL can reproduce that path from metadata it has already validated. Adding another generic and argument to SQLite, test backends, append, and repair makes every implementation carry a concept only the file backend needs.
|
||||
|
||||
@@ -26,4 +26,4 @@ An existing configured JSONL root must be a readable directory when the plugin l
|
||||
|
||||
## Consequences
|
||||
|
||||
Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. The cwd-bucket format stays unchanged and needs no migration. Lookup remains proportional to the number of buckets, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, cwd collision handling, and load-time root validation.
|
||||
Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. Lookup remains proportional to the number of project directories, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, normalized-project collisions and case aliases, and load-time root validation.
|
||||
|
||||
@@ -6,11 +6,11 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd,并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个分桶目录中时,分桶扫描也必须给出确定的结果。SQLite 不存在这种歧义,因为主键查询会将元数据和事件绑定到请求的 id。
|
||||
JSONL 查找会根据请求的会话 id 在各个项目目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd,并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个项目目录中时,项目扫描也必须给出确定的结果。SQLite 不存在这种歧义,因为主键查询会将元数据和事件绑定到请求的 id。
|
||||
|
||||
## 决策
|
||||
|
||||
`loadStored(id)` 是协调器唯一的已存前缀查找操作。JSONL 后端扫描所有 cwd 分桶目录,要求匹配编码文件名的日志至多有一个,解析该文件,然后在返回元数据前同时验证 `header.id === id` 和 `selectedPath === logPath(root, header.cwd, header.id)`。`list()` 执行相同的路径验证,并拒绝跨分桶目录重复的 id。
|
||||
`loadStored(id)` 是协调器唯一的已存前缀查找操作。JSONL 后端扫描所有项目目录,要求名称与该 id 的编码值匹配且其中包含 transcript(文本记录)的会话目录至多有一个,解析其中的 transcript,然后验证 `header.id === id`,并验证选定路径要么等于 `logPath(root, header.cwd, header.id)`,要么经文件系统路径规范化后,两种写法解析为同一份 transcript。`list()` 执行相同的路径验证,并拒绝跨项目目录重复的 id。
|
||||
|
||||
协调器会独立断言返回的 id,并在修复、发布状态或持久化后缀之前比较已存 cwd 和活动会话的 cwd。协调器保留一份已验证元数据的独立副本;JSONL 的追加和修复操作根据该副本派生路径。因此,`PersistenceBackend<TornMarker>` 接口既不需要限定范围的活动会话查找,也不需要存储定位器类型。
|
||||
|
||||
@@ -18,7 +18,7 @@ JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**按会话 id 扁平化存储。** 扁平命名空间会让重复发布在同一路径上冲突,但路径验证和重复项拒绝无需改变按项目分组的 cwd 布局及其消费方,也能消除身份缺陷。
|
||||
**按会话 id 扁平化存储。** 扁平命名空间会让重复发布在同一路径上冲突,但路径验证和重复项拒绝无需让检查依赖扁平的全局命名空间,也能消除身份缺陷。
|
||||
|
||||
**通过协调器传递不透明存储定位器。** 定位器可以将 JSONL 变更直接绑定到选定路径,但 JSONL 可以根据已经验证的元数据重新得到该路径。为 SQLite、测试后端、追加和修复操作增加一个泛型和参数,会让每个实现都承担只有文件后端需要的概念。
|
||||
|
||||
@@ -26,4 +26,4 @@ JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物
|
||||
|
||||
## 后果
|
||||
|
||||
JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。cwd 分桶格式保持不变,无需迁移。查找开销仍与分桶目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝、cwd 冲突处理以及加载时的根目录验证。
|
||||
JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。查找开销仍与项目目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝、项目路径规范化冲突与大小写别名,以及加载时的根目录验证。
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-24-empty-model-response-is-retryable.md: f4a6373178efd5ca1ba5882fb2aaf97dffb2526b
|
||||
2026-07-24-empty-model-response-is-retryable.zh.md: 4c3afe44140c029d274f34ade97803b958c6d669
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Empty model completions are retryable EMPTY_RESPONSE failures
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-24-empty-model-response-is-retryable.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Providers occasionally return a degenerate completion: a well-formed stream that carries a terminal `stop` finish and zero content blocks — no text, no reasoning, no tool calls. If an adapter maps this shape to a successful `{kind: 'stop'}` finish, the loop logs an empty `assistant/message` and ends the turn as `completed`. Retry never runs, no failure reaches the caller, and a driver such as goal-session consumes a round without progress.
|
||||
|
||||
## Decision
|
||||
|
||||
An adapter classifies a completed empty response as a provider-boundary failure, and retry policy treats it as transient:
|
||||
|
||||
- `dsh-llm` exports the canonical code `EMPTY_RESPONSE_CODE` (`'EMPTY_RESPONSE'`) beside `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE`.
|
||||
- `dsh-llm-pi-ai` (`mapStopReason`): a terminal `stop` whose assistant message has no content blocks becomes a `finish {kind: 'error'}` with that code. Context-overflow detection still wins where it applies (it is checked first and is the more actionable classification).
|
||||
- `dsh-llm-deepseek` (`translate`): at `[DONE]`, a `stop` (or absent) finish with no opened blocks becomes the same error finish. Reasoning-only streams count as content and stay successful.
|
||||
- `dsh-llm-retry` adds `EMPTY_RESPONSE` to `DEFAULT_RETRYABLE_CODES`: the attempt produced nothing durable, so repeating it is safe; deployments can still remove it via `retryableCodes`.
|
||||
|
||||
Detection is scoped to `stop` finishes only. `max-tokens` with empty content keeps its existing meaning (pi-ai already normalizes the zero-output overflow case), `tool-calls` cannot be block-empty in practice, and error/aborted finishes already fail.
|
||||
|
||||
The classification uses the existing loop machinery — `finishError` → `agent/request-error` → `dsh-llm-retry` — and keeps `agent-loop` provider-neutral. Exhausting the retry budget ends the turn with an explicit `EMPTY_RESPONSE` failure instead of an empty success.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Detect in the loop or `BlockAssembler`.** One shared implementation, but it moves provider-response judgment into the loop, against "plugins, not loop changes", and the assembler is a pure assembly algorithm. The adapter is where wire facts become harness classification, with the overflow reclassification as exact precedent.
|
||||
|
||||
**A stream-transform plugin on the `llm/stream` waterfall.** Provider-neutral and one implementation, but it adds a package plus wiring for what is a boundary fact each adapter can state in a few lines, and default-on behavior would still require touching every bundle.
|
||||
|
||||
**Treat whitespace-only or reasoning-only responses as empty too.** Rejected as overreach: those carry model-produced content, and misclassifying a legitimate (if useless) response as a transport-class failure risks retry loops on models that intentionally stop after reasoning. The scope is exactly "zero content blocks".
|
||||
|
||||
## Consequences
|
||||
|
||||
- A transiently misbehaving provider consumes a bounded retry instead of a turn with no output; a persistently empty model surfaces an actionable `EMPTY_RESPONSE` turn failure.
|
||||
- A model that genuinely intends to say nothing (rare, but possible after a tool result) is retried and, if consistently empty, fails the turn. This trade was accepted deliberately: an empty assistant message is indistinguishable from the provider defect and has no value to the user.
|
||||
- The `empty-response-retry` ACP snapshot (an authored keyless scenario with a deterministic 1 ms zero-jitter retry overlay, `examples/acp-agent/retry.cordis.yml`) pins the product-visible behavior: a durable `llm/retry` event, no ACP output for the discarded attempt, the recovered reply, and a clean completed turn.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Empty model completions are retryable EMPTY_RESPONSE failures
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-24-empty-model-response-is-retryable.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
提供方偶尔会返回一种退化的 completion:流本身格式完好,携带一个终止性的 `stop` 结束,却没有任何内容块——没有文本、没有 reasoning(推理)、没有工具调用。如果适配器把这种形态映射为成功的 `{kind: 'stop'}` 结束,主循环就会记录一条空的 `assistant/message`,并把该轮次以 `completed` 结束。系统不会重试,失败也不会向调用方暴露,而像 goal-session 这样的驱动方会消耗一个轮次,却没有取得任何进展。
|
||||
|
||||
## Decision
|
||||
|
||||
由适配器把「已完成但为空」的响应归类为一次提供方边界失败,重试策略则将其视为瞬时性问题:
|
||||
|
||||
- `dsh-llm` 在 `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE` 之外,导出规范代码 `EMPTY_RESPONSE_CODE`(`'EMPTY_RESPONSE'`)。
|
||||
- `dsh-llm-pi-ai`(`mapStopReason`):当终止性 `stop` 所对应的 assistant 消息没有内容块时,它会变成一个携带该代码的 `finish {kind: 'error'}`。上下文溢出检测在其适用场景中仍然优先(它先被检查,也是更具可操作性的归类)。
|
||||
- `dsh-llm-deepseek`(`translate`):在 `[DONE]` 处,若 `stop`(或缺失)结束且没有打开过任何块,则同样变成该错误结束。仅含 reasoning 的流算作有内容,仍视为成功。
|
||||
- `dsh-llm-retry` 把 `EMPTY_RESPONSE` 加入 `DEFAULT_RETRYABLE_CODES`:这次尝试没有产生任何持久内容,因此重复它是安全的;部署方仍可通过 `retryableCodes` 将其移除。
|
||||
|
||||
检测仅限于 `stop` 结束。内容为空的 `max-tokens` 保持其既有含义(pi-ai 已经把零输出的溢出场景归一化处理),`tool-calls` 在实践中不可能是空块,而 error/aborted 结束本身已经算失败。
|
||||
|
||||
这套归类使用既有的主循环机制——`finishError` → `agent/request-error` → `dsh-llm-retry`——并让 `agent-loop` 保持提供方无关。重试预算耗尽时,该轮次会以显式的 `EMPTY_RESPONSE` 失败结束,而不是在没有内容的情况下成功结束。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在主循环或 `BlockAssembler` 中检测。** 只需一份共享实现,但这会把对提供方响应的判断挪进主循环,违背「插件优先,而非改动主循环」,且 assembler 是纯粹的组装算法。适配器才是把协议层面的事实转化为 harness 归类的地方,而溢出重归类正是精确的先例。
|
||||
|
||||
**在 `llm/stream` waterfall(瀑布式事件)上做一个流转换插件。** 这种做法提供方无关且只需一份实现,但它为「每个适配器几行就能声明的边界事实」额外增加了一个包和相应接线,而且默认开启的行为仍需改动每一个 bundle。
|
||||
|
||||
**把仅含空白或仅含 reasoning 的响应也当作空响应。** 作为过度设计予以否决:这类响应携带了模型产生的内容,把一个合法(哪怕无用)的响应误判为传输类失败,会在那些故意在 reasoning 之后停止的模型上引发重试循环。其范围严格限定为「零内容块」。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 一个偶发异常的提供方会消耗一次有界重试,而不是一个没有输出的轮次;一个持续返回空内容的模型则会暴露为用户可据以行动的 `EMPTY_RESPONSE` 轮次失败。
|
||||
- 一个确实打算什么都不说的模型(罕见,但在一次工具结果之后有可能出现)会被重试,若始终为空,则该轮次失败。这个取舍是经过审慎权衡后接受的:一条空的 assistant 消息与提供方缺陷无法区分,且对用户毫无价值。
|
||||
- `empty-response-retry` ACP 快照(一个人工编写的无密钥场景,配有确定性的 1 ms 零抖动重试 overlay,`examples/acp-agent/retry.cordis.yml`)钉住了产品可见的行为:持久的 `llm/retry` 事件、被丢弃的尝试不产生任何 ACP 输出、恢复后的回复,以及一次干净的已完成轮次。
|
||||
@@ -1,61 +0,0 @@
|
||||
# Agent Note: Agent Client Protocol (ACP) support — drive the coding agent from external editors
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-06-14-acp-agent-client-protocol.zh.md)
|
||||
|
||||
> Superseded by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). This note records the retired editor-facing bridge design.
|
||||
|
||||
## Problem
|
||||
|
||||
The harness originally exposed agents only through a readline loop. That surface could carry text, but it gave an editor no structured way to create or resume sessions, correlate prompt completion, stream reasoning and tool activity, render tool-specific UI, ask for permission, or cancel one conversation without disturbing another. ACP defines those interactions as JSON-RPC over stdio, and Zed is the target client used to make concrete compatibility decisions.
|
||||
|
||||
The bridge must preserve the harness's existing ownership boundaries. It cannot depend on the concrete agent loop, bypass the tool registry, execute shell commands in the editor, or invent a second source of session truth. stdout is also the protocol transport, so any accidental log output corrupts the connection.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-acp` was a UI/client-driver plugin in the `ui` package group (it now lives in `acp`). It used `@agentclientprotocol/sdk`'s `AgentSideConnection` over stdin/stdout and programmed only interface services: the agent create/resume factory, session persistence, tool registry, user interaction, and optional approval/bash capabilities. It did not change the agent loop and was not a capability-seam implementation.
|
||||
|
||||
The bridge implements the following stable session path:
|
||||
|
||||
- `initialize` negotiates the protocol version, advertises text plus `resource_link` prompts, and advertises `loadSession`.
|
||||
- `session/new` validates an absolute `cwd`, stores it in `SessionHeader`, creates an agent through `ctx.agents`, and returns any composition-backed config options.
|
||||
- `session/load` validates the requested cwd against persisted metadata before constructing an agent, reserves the id across the asynchronous resume, replays user/assistant/tool events as ACP updates, and reports the resumed config-option fold.
|
||||
- `session/prompt` accepts text and resource links, rejects unsupported or empty content, allows one in-flight prompt per session, and settles against that prompt's owning `turn/end`. An error turn rejects the RPC; other closed turn reasons map through a total ACP stop-reason codec.
|
||||
- `session/cancel` calls the queue-aware agent cancel path and settles only the addressed session's prompt.
|
||||
|
||||
Tool-call presentation remains tool-owned. A tool's `presentCall` and `presentResult` return the `generic`, `terminal`, or `diff` render-intent variants; the bridge switches on that union and maps it to ACP. Presenter-less tools receive a generic fallback. Bash terminal cards use Zed's capability-gated `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit` convention; the harness still executes the command through `ctx.bash`, preserving sandbox, environment scrub, ownership, and cwd. Clients without that extension receive ordinary text content. Filesystem tools provide diff cards and file locations without hard-coded tool-name branches in the bridge.
|
||||
|
||||
Permission handling is an answerer on the [user-approval seam](2026-07-06-approval-seam.md), not an ask-every-tool policy in ACP. An `approval/request` for a bridge-owned agent with a call id becomes `session/request_permission` on that agent's editor session, with one-shot allow/reject choices. Foreign or call-less requests delegate; a missing or failed answerer remains fail-closed. The plugin that asks—such as a pre-execute policy or bash escalation—owns the decision to ask.
|
||||
|
||||
When `ctx.permission` is composed, the bridge exposes one `permission` select from the deployment's preset table. The shipped `workspace-write` and `danger-full-access` presets each bundle a sandbox mode with an approval policy; unmatched effective knobs produce the switch-away-only `custom` state. `session/set_config_option` validates through `PermissionService.set()` and writes both owning knob events. A switch during an open turn appends immediately; an idle switch is overlaid in responses and anchored at the next `agent/prompt-submit`, before request assembly. Until then it is memory-only, so a crash restores the durable fold. ACP session modes are not modeled because config options are the forward protocol surface; `AcpConfig.model` remains connection-wide.
|
||||
|
||||
The bridge also provides the ACP-backed `UserInteractionProvider`: `ask_user_question` requests become form elicitations on the owning session. Select, multi-select, option descriptions, and custom-answer override semantics are preserved.
|
||||
|
||||
Lifecycle ownership is explicit. The bridge holds an `AgentHandle` per live session. Disconnect and Cordis disposal cancel pending prompts, dispose every handle in parallel, await loop quiescence and persistence flush, and then remove the records. Stream notification failures are contained so a vanished client cannot corrupt an agent turn. The ACP app composition loads no stdout logger; a test guards stdout as framed JSON-RPC only.
|
||||
|
||||
The current protocol contract lives in the [`dsh-acp` package README](../../../../packages/acp/acp/README.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A prepended `tools/execute` listener that asks on every ACP-owned call** — rejected. It would hard-code permission policy into the UI bridge, ask even when no policy requires it, and could not serve approval requests that arise after execution begins. The shared user-approval seam keeps mechanism, asking policy, and UI answerer separate.
|
||||
|
||||
**Inject the concrete `agentLoop`** — rejected. Agent creation, resume, idle observation, and disposal are interface-level ownership operations on `dsh-agent`; a UI plugin does not need a dependency-rule exception.
|
||||
|
||||
**Execute bash through ACP `terminal/*`** — rejected. That would move execution outside the harness and bypass its sandbox, credential scrub, task ownership, cwd resolution, and session log. Terminal metadata is presentation only.
|
||||
|
||||
**Represent permission presets as ACP session modes** — rejected. The deployment-defined preset is already one config-option select, while session modes are the legacy surface slated for removal in ACP v2.
|
||||
|
||||
**Hijack stdout defensively** — rejected. Process-wide monkey-patching is outside Cordis effect ownership and races the protocol transport. The app composition owns stdout purity.
|
||||
|
||||
## Consequences
|
||||
|
||||
Editors can create, load, prompt, cancel, render, ask, and reconfigure multiple harness sessions over one ACP connection without a loop-specific dependency. The session event log remains the durable source for replay, prompt settlement, cwd, and per-session configuration. Tool presentation and human-answer channels remain extensible plugin contracts instead of ACP-specific behavior.
|
||||
|
||||
The bridge deliberately does not implement session list/delete/resume/close capabilities, MCP passthrough, additional directories, image/audio/embedded-resource prompts, plans, slash commands, usage updates, editor filesystem delegation, or the ACP terminal execution sub-protocol. Runtime model selection was added later through standard session config options by the [LLM catalog and ACP selection Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md).
|
||||
|
||||
An idle config selection is truthful in the live response but not durable until the next `agent/prompt-submit` anchors it inside the open turn. Crashing before that boundary loses the pending selection; this is the cost of keeping session events turn-enclosed and replay-safe.
|
||||
|
||||
## Verification
|
||||
|
||||
The ACP suites cover the in-memory protocol codec, create/load replay, exact prompt settlement, cancellation races, unsupported content, tool presentation, terminal capability fallback, permission outcome mapping, config-option validation and persistence, multi-session isolation, disconnect/disposal quiescence, and HMR cleanup. Snapshot and built-bin tests exercise the app composition, while the real-API e2e self-skips without a key.
|
||||
@@ -1,61 +0,0 @@
|
||||
# Agent Note: Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-14-acp-agent-client-protocol.md) | 中文
|
||||
|
||||
> 已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。本 Agent Note 记录已退役的面向编辑器的桥接层设计。
|
||||
|
||||
## 问题
|
||||
|
||||
harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联提示词完成、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的前提下取消某个对话。ACP(Agent Client Protocol)将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。
|
||||
|
||||
桥接层必须保持 harness 既有的所有权边界。它不能依赖具体的 agent loop(智能体循环),不能绕过工具注册表,不能在编辑器中执行 shell 命令,也不能发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-acp` 曾是 `ui` 包组中的 UI/客户端驱动插件(现位于 `acp`)。它使用 `@agentclientprotocol/sdk` 的 `AgentSideConnection`(基于 stdin/stdout),仅编排接口服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不修改 agent loop,也不是能力 seam 的实现。
|
||||
|
||||
桥接层实现以下稳定的会话路径:
|
||||
|
||||
- `initialize` 协商协议版本,声明支持 text 与 `resource_link` 类型的提示词,并声明 `loadSession` 能力。
|
||||
- `session/new` 校验绝对路径 `cwd`,将其存入 `SessionHeader`,通过 `ctx.agents` 创建 agent,并返回由组合层支持的配置选项。
|
||||
- `session/load` 在构造 agent 之前校验请求的 cwd 与持久化元数据是否一致,在异步恢复期间保留 id,将用户/助手/工具事件作为 ACP update 回放,并报告恢复后的 config-option 折叠结果。
|
||||
- `session/prompt` 接受文本和 resource link,拒绝不支持的或空的内容,每个会话同时只允许一个 in-flight 提示词,并在该提示词所属的 `turn/end` 时结算。错误轮次拒绝 RPC;其他关闭轮次的原因通过一个全覆盖的 ACP stop-reason 编解码器映射。
|
||||
- `session/cancel` 调用队列感知的 agent 取消路径,仅结算被寻址会话的提示词。
|
||||
|
||||
工具调用的展示仍由工具自身负责。工具的 `presentCall` 和 `presentResult` 返回 `generic`、`terminal` 或 `diff` 渲染意图变体;桥接层对该联合类型做 switch 并映射到 ACP。没有 presenter 的工具获得通用回退。Bash 终端卡片使用 Zed 的能力门控约定 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit`;harness 仍通过 `ctx.bash` 执行命令,保留沙箱、环境清洗、所有权和 cwd。不支持该扩展的客户端收到普通文本内容。文件系统工具提供 diff 卡片和文件位置,桥接层中无需硬编码工具名分支。
|
||||
|
||||
权限处理是[用户审批 seam](2026-07-06-approval-seam.md)上的一个 answerer,而非 ACP 中的「每次工具调用都询问」策略。对桥接层所属 agent 且带有 call id 的 `approval/request`,会变为该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。外部请求或无 call id 的请求委托给下游;缺失或失败的 answerer 会在故障时保持拒绝。发起询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。
|
||||
|
||||
当 `ctx.permission` 被组合时,桥接层从部署的预设表中暴露一个 `permission` select。已发布的 `workspace-write` 和 `danger-full-access` 预设各自捆绑一个沙箱模式与一条审批策略;无法匹配的有效旋钮组合产生只能切走的 `custom` 状态。`session/set_config_option` 通过 `PermissionService.set()` 校验并写入两个所属旋钮事件。在开放轮次中的切换立即追加;空闲时的切换叠加在响应中,并在下一次 `agent/prompt-submit` 时锚定到开放轮次之前的请求组装阶段。在此之前它仅存于内存,因此崩溃后恢复的是持久化的折叠结果。ACP session mode 不被建模,因为 config option 是面向未来的协议表面;`AcpConfig.model` 保持连接级别。
|
||||
|
||||
桥接层还提供基于 ACP 的 `UserInteractionProvider`:`ask_user_question` 请求变为所属会话上的表单引导。select、multi-select、选项描述与自定义回答覆盖语义均被保留。
|
||||
|
||||
生命周期所有权是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的提示词,并行 dispose 所有 handle,等待循环完全停稳与持久化刷写,然后移除记录。流通知失败被隔离,因此消失的客户端不会破坏 agent 轮次。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。
|
||||
|
||||
当前的协议契约见 [`dsh-acp` 包 README](../../../../packages/acp/acp/README.md)。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**在 `tools/execute` 监听器前置一层,对每个 ACP 所属调用都询问权限**:否决。这会将权限策略硬编码到 UI 桥接层,即使没有策略要求也会询问,且无法服务于执行开始后才产生的审批请求。共享的 user-approval seam 将机制、询问策略和 UI answerer 分离。
|
||||
|
||||
**注入具体的 `agentLoop`**:否决。agent 的创建、恢复、空闲观察与释放是 `dsh-agent` 上的接口级所有权操作;UI 插件不需要依赖规则例外。
|
||||
|
||||
**通过 ACP `terminal/*` 执行 bash**:否决。这会将执行移到 harness 之外,绕过其沙箱、凭证清洗、任务所有权、cwd 解析与会话日志。终端元数据仅用于展示。
|
||||
|
||||
**将权限预设表示为 ACP session mode**:否决。部署定义的预设已经是一个 config-option select,而 session mode 是 ACP v2 计划移除的遗留接口。
|
||||
|
||||
**防御性劫持 stdout**:否决。进程级 monkey-patching 超出 Cordis 副作用所有权范围,且与协议传输存在竞争。应用组合拥有 stdout 纯净性。
|
||||
|
||||
## 后果
|
||||
|
||||
编辑器可以通过一条 ACP 连接创建、加载、提交提示词、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、提示词结算、cwd 与每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。
|
||||
|
||||
桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源提示词、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。后续已通过标准会话配置选项加入运行时模型选择,见 [LLM 目录与 ACP 选择 Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md)。
|
||||
|
||||
空闲时的配置选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到开放轮次之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件封闭于轮次内且回放安全的代价。
|
||||
|
||||
## 验证
|
||||
|
||||
ACP 测试套件覆盖内存协议编解码器、创建/加载回放、精确的提示词结算、取消竞争、不支持的内容、工具展示、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/释放后的完全停稳,以及 HMR(热模块替换)清理。快照测试与 built-bin 测试验证应用组合,真实 API 的 e2e 测试在无 key 时自动跳过。
|
||||
@@ -1,50 +0,0 @@
|
||||
# Agent Note: Rich ACP bash rendering — the terminal card via the `_meta` convention
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-06-18-acp-terminal-and-tool-rendering.zh.md)
|
||||
|
||||
> Superseded for ACP by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). Tool render intents remain available to UI transports, but ACP no longer projects them into terminal cards.
|
||||
|
||||
## Problem
|
||||
|
||||
The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](2026-06-14-acp-agent-client-protocol.md) and `packages/core/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block.
|
||||
|
||||
Reference editors render terminal metadata as a dedicated card with cwd, command, live-style output, and exit status; plain text loses that structure. The command is the title because execute cards hide raw input, while the human-readable description remains a separate block above the card.
|
||||
|
||||
## Key finding: agent-executed terminals use a `_meta` convention, NOT `terminal/create`
|
||||
|
||||
The ACP spec has a *client-side* terminal sub-protocol — the agent calls the client's `terminal/create` with `{ command, args, cwd, env }` and the **editor** executes the process, then the agent reads `terminal/output` / `wait_for_exit`. That model is wrong for us: our harness executes bash itself through `dsh-bash` (sandboxed env-scrub, background-task ownership, per-session cwd). Routing execution to the editor would bypass all of that and fork execution into two backends.
|
||||
|
||||
Studying the two reference agents (2026-06-18) shows neither uses `terminal/create` for their own shell tool — **both keep agent-side execution and emit a `_meta` convention** that Zed special-cases:
|
||||
|
||||
- **`claude-agent-acp`** (`tools.ts`, `acp-agent.ts`): gated on `clientCapabilities._meta.terminal_output`. The `tool_call` carries `content: [{ type: 'terminal', terminalId }]` and `_meta.terminal_info.{ terminal_id, cwd }`; output/exit arrive on the `tool_call_update`'s `_meta.terminal_output.{ terminal_id, data }` and `_meta.terminal_exit.{ terminal_id, exit_code, signal }`.
|
||||
- **`codex-acp`** (`CodexToolCallMapper.ts`, `TerminalOutputMode.ts`): same `terminal_info` on the call; output via `_meta.terminal_output` (full) or `_meta.terminal_output_delta` (incremental), selected from the same `_meta.terminal_output` capability.
|
||||
|
||||
Zed's side (`crates/agent_servers/src/acp.rs`, verified): on a `ToolCall` whose `_meta.terminal_info.terminal_id` is set, it registers a **display-only** terminal (header = `terminal_info.cwd`, label = `tool_call.title`); on a `ToolCallUpdate`, `_meta.terminal_output.data` writes to that terminal and `_meta.terminal_exit.{exit_code,signal}` sets the status. It advertises the capability as `clientCapabilities._meta.terminal_output = true`. `_meta` itself is a spec-blessed ACP extensibility point (typed `{[k]: unknown} | null` on `ToolCall`/`ToolCallUpdate`); the *specific keys* here (`terminal_info`/`terminal_output`/`terminal_exit`) are a Zed convention, not part of the ACP spec — but they are the de-facto contract for the Zed integration and the only way to get the terminal card while keeping execution agent-side.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep `dsh-bash` agent-side execution; render the terminal card via the `_meta` convention, capability-gated, with the ` ```console ` text block as the guaranteed fallback.
|
||||
|
||||
1. **Capability.** `initialize` reads `clientCapabilities._meta.terminal_output` and the bridge remembers it per connection.
|
||||
2. **Neutral presentation vocabulary.** `dsh-tools` gains a terminal-shaped presentation a tool can return — provider-neutral (`cwd`, the output `data`, an `exitCode`/`signal`), NO ACP types. `dsh-tool-bash` returns it for `bash` (cwd from the resolved workdir; output + exit parsed from the run result).
|
||||
3. **Bridge mapping.** When the client advertised the capability, the bridge maps that presentation to: on `tool_call`, `content:[…, {type:'terminal', terminalId}]` (any tool `content`, e.g. the description, rendered BEFORE the terminal block) + `_meta.terminal_info.{terminal_id,cwd}`; on `tool_call_update`, `_meta.terminal_output.{terminal_id,data}` (the captured output) + `_meta.terminal_exit.{terminal_id, exit_code|signal}` (the parsed exit), with the update's text `content` OMITTED (an ACP `tool_call_update.content` REPLACES the call's content collection, so re-sending the fenced block would clobber the terminal content block). `terminalId` is derived from the harness `callId` (stable, unique per call). When the capability is absent, the bridge sends the description content block on the call and the existing ` ```console ` text content on the update — unchanged.
|
||||
4. **The exit pill is parsed from the rendered output; no new execution path, no live streaming.** Output is attached at completion (from the agent's own `tool/result`), not streamed token-by-token. The exit-status pill (`_meta.terminal_exit.{exit_code,signal}`) IS emitted: the pure `presentResult(args, result)` seam sees only content blocks, so `dsh-tool-bash` recovers the structured exit by parsing the status markers (`[exit code: N]` / `[killed by signal: …]`) that `renderResult` appended — the parse is the exact inverse of the marker emission, the two co-evolve in one file, and a round-trip test guards the pair. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **The ACP client-side terminal sub-protocol (`terminal/create`)** — explicitly rejected: the editor would execute the process, bypassing `dsh-bash`'s env scrub, background-task ownership, and per-session cwd, and forking execution into two backends. Both reference agents reject it the same way (the key finding above); agent-side execution plus the `_meta` convention is the only shape that yields the terminal card while keeping the harness's execution policy.
|
||||
- **Threading a structured exit through the event schema** — rejected in favor of the marker round-trip: the pure `presentResult(args, result)` seam sees only content blocks, and the parse is the exact inverse of the marker emission, co-evolving in one file under a round-trip test.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Zed-convention `_meta` keys.** The terminal card rides on Zed-specific keys (`terminal_info`/`terminal_output`/`terminal_exit`) inside ACP's spec-blessed `_meta` extensibility point, NOT on the ACP terminal sub-protocol. A client that doesn't recognize the keys still gets the text fallback (the capability gate ensures we only emit them when the client opted in via `_meta.terminal_output`), so a non-Zed client is never worse off. If ACP later standardizes agent-executed terminals, migrate to that and drop the convention keys.
|
||||
- **Capability honesty.** Emit terminal metadata ONLY when the client advertised `_meta.terminal_output`; the text fallback is the contract for everyone else and must never regress. Covered by a no-capability test asserting the ` ```console ` path.
|
||||
- **terminalId collisions.** Deriving it from the per-call `callId` keeps it unique within a session and stable across the call/result pair; never reuse one across calls.
|
||||
- **Exit parsed from rendered text.** The exit pill recovers `exit_code`/`signal` by parsing `renderResult`'s status markers rather than threading a structured exit through the event schema (which the pure `presentResult` seam never sees). The parse is the exact inverse of the marker emission and lives in the same file; a round-trip test pins the pair so a marker-format change that breaks the parse fails the suite. If the markers ever need to diverge from what the pill wants, surface a structured exit on the result event instead.
|
||||
- **Provider-neutral vocabulary creep.** The terminal presentation widens the `dsh-tools` surface; keep it neutral (no ACP types leak into `dsh-tools`) and only as rich as a second UI consumer would also want.
|
||||
|
||||
## Out of scope / non-goals
|
||||
|
||||
The text-block baseline stays the no-capability default. Two follow-ups are deliberately NOT built here and would each warrant their own Agent Note when someone takes them on: **live incremental streaming** (`_meta.terminal_output_delta` as chunks arrive, which needs an incremental-output seam on `dsh-bash`), and **command classification** (parsing a `cat`/`sed` as a `read` card with a file location, a `grep` as a `search`, etc., falling back to the terminal card — display-only, must never change what executes).
|
||||
@@ -1,50 +0,0 @@
|
||||
# Agent Note: 富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文
|
||||
|
||||
> 就 ACP 而言已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。工具渲染意图对 UI 传输层仍然可用,但 ACP 不再将其投影为终端卡片。
|
||||
|
||||
## 问题
|
||||
|
||||
ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见[工具调用 UI 呈现](2026-06-14-acp-agent-client-protocol.md)与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。
|
||||
|
||||
参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格的输出和退出状态;纯文本则丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。
|
||||
|
||||
## 关键发现:agent 执行的终端使用 `_meta` 约定,而非 `terminal/create`
|
||||
|
||||
ACP 规范有一个*客户端侧*终端子协议:agent(智能体)调用客户端的 `terminal/create`(传入 `{ command, args, cwd, env }`),由**编辑器**执行进程,然后 agent 读取 `terminal/output` / `wait_for_exit`。这个模型不适合我们:我们的 harness 通过 `dsh-bash` 自行执行 bash(沙箱化的环境清理、后台任务所有权、按会话的 cwd)。将执行路由到编辑器会绕过所有这些机制,并将执行分叉到两个后端。
|
||||
|
||||
研究两个参考 agent(2026-06-18)发现,二者都没有为自己的 shell 工具使用 `terminal/create`——**两者都保持 agent 侧执行,并发出一套 `_meta` 约定**,由 Zed 特殊处理:
|
||||
|
||||
- **`claude-agent-acp`**(`tools.ts`、`acp-agent.ts`):以 `clientCapabilities._meta.terminal_output` 为门控。`tool_call` 携带 `content: [{ type: 'terminal', terminalId }]` 与 `_meta.terminal_info.{ terminal_id, cwd }`;输出和退出通过 `tool_call_update` 的 `_meta.terminal_output.{ terminal_id, data }` 与 `_meta.terminal_exit.{ terminal_id, exit_code, signal }` 到达。
|
||||
- **`codex-acp`**(`CodexToolCallMapper.ts`、`TerminalOutputMode.ts`):调用上同样携带 `terminal_info`;输出通过 `_meta.terminal_output`(完整)或 `_meta.terminal_output_delta`(增量),由同一个 `_meta.terminal_output` 能力选择。
|
||||
|
||||
Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall` 且其 `_meta.terminal_info.terminal_id` 已设置时,注册一个**仅展示**的终端(header = `terminal_info.cwd`,label = `tool_call.title`);收到 `ToolCallUpdate` 时,`_meta.terminal_output.data` 写入该终端,`_meta.terminal_exit.{exit_code,signal}` 设置状态。客户端通过 `clientCapabilities._meta.terminal_output = true` 声明此能力。`_meta` 本身是 ACP 规范认可的扩展点(在 `ToolCall`/`ToolCallUpdate` 上类型为 `{[k]: unknown} | null`);这里的*具体键*(`terminal_info`/`terminal_output`/`terminal_exit`)是 Zed 约定,不属于 ACP 规范,但它们是 Zed 集成的事实契约,也是在保持 agent 侧执行的前提下获得终端卡片的唯一方式。
|
||||
|
||||
## 决策
|
||||
|
||||
保持 `dsh-bash` 的 agent 侧执行;通过 `_meta` 约定渲染终端卡片,以能力声明为门控,以 ` ```console ` 文本块作为保底回退。
|
||||
|
||||
1. **能力声明。** `initialize` 读取 `clientCapabilities._meta.terminal_output`,桥接层按连接记住它。
|
||||
2. **提供方无关的展示词汇。** `dsh-tools` 新增一种终端形态的展示结构,工具可返回它——提供方无关(`cwd`、输出 `data`、`exitCode`/`signal`),不含 ACP 类型。`dsh-tool-bash` 为 `bash` 返回该结构(cwd 来自解析后的工作目录;输出与退出从运行结果解析)。
|
||||
3. **桥接映射。** 当客户端声明了该能力时,桥接层将展示结构映射为:在 `tool_call` 上,`content:[…, {type:'terminal', terminalId}]`(工具的任何 `content`,如描述,渲染在终端块之前)+ `_meta.terminal_info.{terminal_id,cwd}`;在 `tool_call_update` 上,`_meta.terminal_output.{terminal_id,data}`(捕获的输出)+ `_meta.terminal_exit.{terminal_id, exit_code|signal}`(解析后的退出),且 update 的文本 `content` 被省略(ACP 的 `tool_call_update.content` 会替换调用的 content 集合,因此重新发送围栏块会覆盖终端内容块)。`terminalId` 由 harness 的 `callId` 派生(稳定、每次调用唯一)。当能力未声明时,桥接层在调用上发送描述内容块,在 update 上发送既有的 ` ```console ` 文本内容——行为不变。
|
||||
4. **退出信息从渲染输出中解析;无新执行路径,无实时流式传输。** 输出在完成时附加(来自 agent 自身的 `tool/result`),不逐 token 流式传输。退出状态(`_meta.terminal_exit.{exit_code,signal}`)确实会发出:纯 `presentResult(args, result)` seam 只能看到内容块,因此 `dsh-tool-bash` 通过解析 `renderResult` 追加的状态标记(`[exit code: N]` / `[killed by signal: …]`)来恢复结构化退出信息——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。资源释放不受影响:无需新增拆除逻辑,因为桥接层从未创建客户端侧终端。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **ACP 客户端侧终端子协议(`terminal/create`)**:明确否决。编辑器将执行进程,绕过 `dsh-bash` 的环境清理、后台任务所有权和按会话的 cwd,并将执行分叉到两个后端。两个参考 agent 以同样的方式否决了它(见上述关键发现);agent 侧执行加 `_meta` 约定是在保持 harness 执行策略的同时获得终端卡片的唯一形态。
|
||||
- **通过事件 schema 传递结构化退出信息**:否决,改用标记往返方案。纯 `presentResult(args, result)` seam 只能看到内容块,而解析是标记发出的精确逆操作,二者在同一文件中共同演进,由往返测试守护。
|
||||
|
||||
## 后果
|
||||
|
||||
- **Zed 约定的 `_meta` 键。** 终端卡片依赖 Zed 特有的键(`terminal_info`/`terminal_output`/`terminal_exit`),位于 ACP 规范认可的 `_meta` 扩展点内,而非 ACP 终端子协议。不识别这些键的客户端仍然获得文本回退(能力门控确保我们仅在客户端通过 `_meta.terminal_output` 声明支持时才发出这些键),因此非 Zed 客户端不会变差。如果 ACP 日后标准化了 agent 执行的终端,则迁移到该标准并移除约定键。
|
||||
- **能力诚实。** 仅在客户端声明了 `_meta.terminal_output` 时才发出终端元数据;文本回退是对其他所有客户端的契约,绝不可退化。由一个无能力测试覆盖,断言 ` ```console ` 路径。
|
||||
- **terminalId 冲突。** 从每次调用的 `callId` 派生,保证在会话内唯一且在 call/result 对之间稳定;绝不跨调用复用。
|
||||
- **退出信息从渲染文本解析。** 退出信息通过解析 `renderResult` 的状态标记恢复 `exit_code`/`signal`,而非通过事件 schema 传递结构化退出(纯 `presentResult` seam 看不到后者)。解析是标记发出的精确逆操作,且位于同一文件中;往返测试固定了这对关系,标记格式变更若破坏解析则测试套件失败。如果标记格式日后需要与退出信息分道扬镳,则改为在 result 事件上暴露结构化退出。
|
||||
- **提供方无关词汇的蔓延。** 终端展示结构扩大了 `dsh-tools` 的接口面;保持其中立性(不让 ACP 类型泄漏到 `dsh-tools`),且只提供第二个 UI 消费方同样需要的丰富度。
|
||||
|
||||
## 超出范围 / 非目标
|
||||
|
||||
文本块基线仍为无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 Agent Note:**实时增量流式传输**(在分片到达时发出 `_meta.terminal_output_delta`,需要在 `dsh-bash` 上新增增量输出 seam);**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片,将 `grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-11-custom-schema-dsl.md: 947d53555df078bfa9f3dac48eab4b8c0074007c
|
||||
2026-06-11-custom-schema-dsl.zh.md: 26ebfe2fb15a6c034e809b3f51187342fa500193
|
||||
2026-06-24-workspace-context.md: 6acdb6241bcc57250e217cfc8856e8b0598d4622
|
||||
2026-06-24-workspace-context.zh.md: f165d08108931df21697c7895523f10ef816297f
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-06-24-workspace-context.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Repository guidance such as `AGENTS.md` belongs in a coding session's effective context so project conventions, build commands, and review rules arrive without repeated user pasting. The stdio and ACP products need the same behavior, isolated by session cwd: a global system-prompt section leaks one workspace's files into another live ACP session.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Agent Note: 工作区上下文指令文件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-24-workspace-context.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`AGENTS.md` 等仓库指引应当进入编码会话的有效上下文,使项目约定、构建命令和评审规则无需由用户反复粘贴即可生效。stdio 与 ACP(Agent Client Protocol)产品需要具备相同行为,并按会话 cwd 隔离:全局系统提示词章节会把一个工作区的文件泄漏到另一个仍在运行的 ACP 会话中。
|
||||
|
||||
相邻产品形成了值得借鉴的约定,但具体做法各不相同。Codex 原生使用 `AGENTS.md`;Claude Code 使用 `CLAUDE.md`,并采用熟悉的 system-reminder 风格用户上下文;opencode 同时支持这两个名称,每个目录只选一个胜出者,并延迟发现嵌套文件。harness 需要跨工具兼容,同时避免从同一作用域加载重复或互相矛盾的文件。
|
||||
|
||||
生命周期中有两类截然不同的内容。初始适用文件链足够稳定,可以放入请求前缀并受益于提供方前缀缓存。嵌套文件、编辑、候选项切换和移除都发生在会话启动后,应进入持久的仅追加历史,而不是被冻结的前缀。
|
||||
|
||||
## 决策
|
||||
|
||||
该实现在 `packages/context/workspace-context` 中,包(package)名为 `@deepseek-ai/dsh-workspace-context`。它是请求上下文扩展,不是核心服务或文件系统后端。共享 demo 主干与 Host Runtime 根据显式的 `{ maxBytes } | false` 部署选择挂载它;`dsh web` 启用 65,536 字节预算,Host Runtime 的 headless 消费方则禁用它。该插件使用 `agent/session-prefix`、`tools/post-execute` 和可选的 `ctx.fs` 功能。
|
||||
|
||||
插件不会静态注入 `fs`。因此,不带提供方的产品树仍能正常启动;在文件系统提供方出现之前,插件保持无操作。所有生产读取都通过该提供方完成。候选项探测会解析每个路径并对结果执行 stat,因此会跟随最终路径组件的符号链接至其目标:指向普通文件的链接会被加载,缺失路径或非文件目标则确认为不存在。允许仓库拥有的链接跨越信任边界,是对最初不跟随探测方式的刻意反转;[跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明该决策及其残余风险。会话前缀信号与动态工具执行信号会贯穿解析、元数据探测和流式读取,因此取消不会等待无关的文件系统扫描。解析或 stat 异常归类为不可用:它只跳过该候选项,绝不被解释为已经加载的作用域被删除。
|
||||
|
||||
### 文件名与优先级
|
||||
|
||||
默认的逐目录候选列表是 `['AGENTS.md', 'CLAUDE.md']`。该列表可通过 `instructionFileCandidates` 配置;`AGENTS.md` 是普通的第一候选项,而不是隐藏优先级。一个目录中只加载第一个存在的普通文件候选项。使用默认值时,`AGENTS.md` 是原生文件,`CLAUDE.md` 是兼容性回退。第二个列表 `localInstructionFileCandidates`(默认为 `['AGENTS.local.md', 'CLAUDE.local.md']`)会在同一目录的基础文件后加载叠加式本地覆盖层;[默认本地覆盖层记录](2026-07-21-local-instruction-overlay.md)负责说明该决策。
|
||||
|
||||
候选条目必须是同一目录中的文件名。空条目、`.`/`..`,以及包含 `/` 或 `\` 的条目会被忽略。其他同目录名称可以显式选择加入;规则目录和导入语义不属于本契约。
|
||||
|
||||
用户全局文件固定为 `$DSH_HOME/AGENTS.md`,不受任一候选列表影响,也没有本地覆盖层。`$DSH_HOME` 默认为 `~/.dsh`,与 `~/.codex` 或 `~/.claude` 在 harness 层的 home 角色一致,而不会引入插件专用 home。波浪号展开与默认值位于 `dsh-paths` 中,以便未来的 harness 功能共享同一约定。
|
||||
|
||||
### 基线前缀
|
||||
|
||||
agent loop(智能体循环)实例的第一次请求会让插件通过 `agent/session-prefix` 提供一条 user 角色消息。它先加载用户全局文件,再从 `agent.session.header.cwd` 向上遍历至配置的根标记(默认为 `.git`)以确定项目根目录,随后从根目录至 cwd 的每级目录各加载一个候选项。`.git` 文件与 `.git` 目录都是有效标记,因而能覆盖链接 worktree 和 submodule。找不到标记时,cwd 本身就是根目录。
|
||||
|
||||
插件会在 `await next()` 返回前前置其贡献,因此会话前缀贡献按插件注册顺序出现。在产品主干中,工作区指令的注册先于 skill 目录,所以它排在前面。循环会深度冻结组合后的前缀,将其记录在 `EpochHeader.messagePrefix` 中,并在该实例内逐字复用。它是请求状态,不是 `Session.deriveMessages()` 历史。
|
||||
|
||||
恢复 agent 会创建新的循环实例,并使用当前文件重新组合基线;新的前缀由恢复请求 header 锚定。这样,恢复时可以使用当前基线内容,而无需修改先前实例已经使用过的前缀。
|
||||
|
||||
基线是一条 user 角色的 `<system-reminder>`,包含 `Instructions from: <path>` 章节,以及明确的权威性与优先级说明。这种熟悉的模型可见框架避免引入 harness 专用的 XML 词汇。项目路径相对于根目录;使用默认 home 时,用户全局路径为 `~/.dsh/AGENTS.md`,使用已配置 home 时则为 `$DSH_HOME/AGENTS.md`。文件内容中的字面量 `</system-reminder>` 会被转义。包 README 负责规定当前准确的[提示词形态](../../../../packages/context/workspace-context/README.md#prompt-shape)。
|
||||
|
||||
### 动态发现与刷新
|
||||
|
||||
第一方 `read`、`write` 或 `edit` 调用成功后,`tools/post-execute` 监听器会协调被触及的后代路径链,以及该会话已经知道的每个作用域。新到达的作用域通过 `additionalContexts` 返回,并在下一次请求中使用 `Additional instructions from: <path>` system-reminder。在 Code Mode 下,`run_code` 会把子分发上下文延后至其外层结果,因此同一更新只会在父结果之后追加,而不会在调用中途注入。
|
||||
|
||||
内容编辑会追加 `Updated instructions from: <path>`,说明新内容取代先前内容,并包含当前的完整文件。如果优先级从一个候选项变为另一个,消息还会指出先前路径并说明它不再适用。如果没有候选项保留,插件会追加 `Instructions removed: <path>`,并说明先前加载的指令不再适用。
|
||||
|
||||
动态消息在 `content` 中携带完整的 system-reminder 框架;每个 `context/message` 都作为 user 角色消息逐字抵达模型,核心层不会再添加可选择退出的包装。`context/message.meta` 携带不透明 JSON 状态,该状态会持久化,但绝不会渲染给模型。
|
||||
|
||||
shell 命令不会触发发现。本地 bash 调用会启动全新的 shell,而从任意命令字符串推断已到达路径,需要实现提示词插件并不拥有的 shell 语义。
|
||||
|
||||
### 重复抑制与变更检测
|
||||
|
||||
每个动态工作区上下文事件都会存储带版本的元数据,其形态为 `{ action, scope, path, digest? }`;`digest` 是对已加载内容计算的 SHA-1。模型可见提示词中没有 HTML 注释、隐藏标记,也没有会被解析回状态的标题。
|
||||
|
||||
协调时,插件扫描自身拥有的 `context/message` 事件,并派生每个可见作用域的最新状态。一个简短的逐会话待处理映射只会在不可变的顶层 `tools/result` 证明某个 `additionalContexts` 条目经过所有 post-execute 监听器后仍然保留时开始记录;随后,它覆盖循环将该上下文追加到日志之前的间隔。每个条目记录开启状态的 `{ turn, step }`:如果相同的持久 `context/message` 出现在其序列边界或之后,该条目得到确认并被移除;如果匹配的 `step/end` 先到达,则说明循环丢弃了上下文缓冲区,插件会同时移除待处理条目及其版本缓存快速路径。嵌套的 Code Mode 结果会把变更暂存在父级的不透明执行 token 下,确保一次运行中的重复子分发不会产生重复项;父级结果会回滚这份临时状态,并且只提交外层策略保留的上下文。
|
||||
|
||||
路径和 digest 均未变化时会被抑制。日志中的移除操作是一条墓碑记录,因此重新出现的候选项会成为新的 `set`。恢复操作从持久化元数据继续工作。如果压缩(compaction)从可见表面移除某条指令事件,该状态不再抑制后续加载,这与模型已经无法看见它的事实一致。只有真正纳入字节预算的变更才会进入元数据或待处理状态,因此被省略的文件在之后的触碰中仍有资格加载。
|
||||
|
||||
被冻结的基线会保留一个内存中的 path/digest map 以供比较。后续成功的文件系统触碰会把基线编辑或移除操作追加为动态消息,绝不重写前缀。恢复时重新组合前缀的过程中,插件还会协调可见的动态作用域,因此 agent 离线期间发生的嵌套变更可以在第一次恢复请求前追加更新。
|
||||
|
||||
系统刻意不使用文件监视器。检测发生在下一次成功的结构化文件系统触碰或恢复时的前缀组合。提供方失败不会产生移除;只有该作用域中的全部已配置候选项都成功完成探测后,系统才接受「不存在」这一结论。
|
||||
|
||||
### 字节预算与有界读取
|
||||
|
||||
`maxBytes` 是必填项,分别作用于渲染后的基线或单个动态协调批次;系统不存在隐式或无界的渲染预算。非正数或非有限值会禁用加载。内容超过预算时,系统会先省略较宽泛的文件,再截断最具体的文件。可见的 `Workspace instruction budget ...` 提示会指出被省略和截断的路径与字节数,并且输出绝不超过配置字节数。
|
||||
|
||||
`maxSourceBytes` 是正数的逐文件上限,默认为 1 MiB。loader 会在读取前检查报告的大小,同时仍通过 `streamText()` 消费内容并持续统计 UTF-8 字节数,因此缺失/陈旧的元数据无法迫使其进行无界分配。过大的胜出候选项会被视为不可用,而不是改为同目录中的下一个名称。插件刻意不保留进程级缓存,也绝不保留指令正文。它只为每个有效作用域保存 `{ path, version, digest }`,并将这些状态放在 `WeakMap<Session, Map<scope, state>>` 中:提供方 `FsVersion` 与有效提示词状态同时匹配时跳过读取;版本变化则触发有界读取和 SHA-1 确认。SHA-1 仍是持久化在可见结构化元数据中的跨提供方内容标识;提供方版本只作为内存中的失效快速路径。模型可见变更的缓存转换只有在相应上下文通过完整的工具结果策略链后才会提交;如果该已接受上下文随后与中止步骤一起被丢弃、未能进入日志,缓存转换就会失效。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**使用全局 `ctx.systemPrompt.section()`。** 不予采纳,因为同一个 Cordis 上下文可以承载 cwd 不同的多个会话,而仓库所有的文本属于低权威用户上下文,不是最高权威的提供方系统内容。
|
||||
|
||||
**在每次 `agent/pre-step` 时注入基线。** 不予采纳,因为重复注入历史会浪费 token、使重复状态复杂化,并妨碍提供方前缀保持结构稳定。前缀组合提供冻结、已记录且逐实例的基线,动态仅追加消息则负责变更。
|
||||
|
||||
**在一个目录中同时加载 `AGENTS.md` 和 `CLAUDE.md`。** 不予采纳,因为正在迁移的仓库通常会在两个文件中重复指引。按顺序排列的候选项让优先级显式且可配置。
|
||||
|
||||
**解析渲染后的标题或隐藏注释以恢复已加载状态。** 不予采纳,因为指令正文可能包含相同文本,导致无提示的误报。持久化 JSON 元数据提供明确且对模型不可见的状态通道。
|
||||
|
||||
**使用模型总结文件。** 不予采纳,因为指令文件本身已经是经过整理的摘要;再执行一次模型调用既不确定,也可能抹掉边界情况要求。使用带字节预算的确定性全文更简单。
|
||||
|
||||
## 影响
|
||||
|
||||
工作区指引按会话隔离,并由 demo 前端、Web Host 与每一种工具展示模式共享。初始指令受益于稳定的前缀缓存,嵌套与变更内容则保持持久且可回放。通用的 session/agent 上下文契约通过 prompt-submit 与工具执行后的 `additionalContexts` 数组携带 JSON 元数据,而不会把条目展平。
|
||||
|
||||
仓库文本仍是不受信任的输入。低权威 user 角色框架、显式优先级说明和分隔符转义可以降低风险,但无法消除提示词注入。跟随候选符号链接到目标,会把该接口扩大至树外内容;因此,把 `ctx.fs` 限制在可信根目录内的权限与沙箱层才是真正的边界,它们让系统把工作区文件当作数据而不是权威([跟随指令符号链接记录](2026-07-21-follow-instruction-symlinks.md)负责说明残余风险)。
|
||||
|
||||
系统由事件驱动,而不是文件监视器驱动。除非文件系统变更通过结构化工具完成,否则编辑不会在确切的文件系统变更时刻可见;外部文件变更会在下一次成功的结构化触碰或恢复时被发现。这使设计保持确定性并且与提供方无关。
|
||||
|
||||
## 延后事项
|
||||
|
||||
从 bash 派生路径报告、递归启动扫描、文件监视器、小写默认名称、`.claude/CLAUDE.md`、`.claude/rules/*.md`、导入指令、ACP `additionalDirectories`、信任确认和模型生成摘要均延后处理。项目目录中的 `.local.` 覆盖层现已默认加载([默认本地覆盖层记录](2026-07-21-local-instruction-overlay.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
|
||||
2026-07-06-approval-seam.md: 852108f22be22eeba4578032924adb546ee10985
|
||||
2026-07-06-approval-seam.zh.md: 9a08f333e0859e6d71f40b039f4b441028c38dc3
|
||||
2026-07-06-approval-seam.md: 70ccd4d486ad6e0126fa2eb638a064e9fc89bba6
|
||||
2026-07-06-approval-seam.zh.md: d218f79888957735305db14cd97cc74480297d29
|
||||
|
||||
@@ -69,7 +69,7 @@ The seam also owns the session-scoped `'ask' | 'never'` policy described by [the
|
||||
|
||||
The ACP bridge answers only for an exact agent object owned by its session map. It sends `session/request_permission` with the existing `callId`, advertises one-shot allow/reject options, maps cancellation separately, and never grants an unknown option. Foreign or call-less requests delegate; a failed client RPC becomes `unavailable`. Hooks and `tools/pre-execute` decide whether a call asks at all. This channel is machine policy between an automated client and its agent, not ACP presentation.
|
||||
|
||||
The answerer routes through the bridge's exact-agent ownership check described by [the ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md), preserving the per-session permission ownership required by [the multi-session Agent Note](2026-06-14-acp-multi-session.md).
|
||||
The answerer routes through the bridge's exact-agent ownership check described by [the automation-only ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md), preserving the per-session permission ownership required by [the multi-session Agent Note](2026-06-14-acp-multi-session.md).
|
||||
|
||||
#### Audit, and what the model sees
|
||||
|
||||
@@ -135,5 +135,5 @@ In-repo precedents this design copies or contrasts with:
|
||||
- The `fs/write-intent` gate (`packages/fs/fs/`) — the documented single-occupancy decision-slot waterfall semantics (first answer wins, delegate via `next()`) the answerer contract reuses.
|
||||
- `hook/invoked`/`hook/result` — the log-only audit-pair precedent `approval/asked`/`approval/decided` follows; [the hook-bridges Agent Note](2026-06-30-hook-bridges.md) ships `permissionDecision: ask`, the first producer.
|
||||
- [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary whose `ask` this seam services.
|
||||
- [The ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md) — the exact-agent ownership check against the session map that the answerer routes through; [the multi-session Agent Note](2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements.
|
||||
- [The automation-only ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md) — the exact-agent ownership check against the session map that the answerer routes through; [the multi-session Agent Note](2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements.
|
||||
- The opportunistic `ctx.get()` consumption pattern (`tool-bash`'s owner-token lookup, the loop's persistence probe) — how `dsh-tools` consumes the seam without gating its fiber on it.
|
||||
|
||||
@@ -69,7 +69,7 @@ seam 还拥有[沙箱 Agent Note](2026-07-06-sandbox.md) 所描述的会话级 `
|
||||
|
||||
ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 `callId` 发送 `session/request_permission`,声明一次性的 allow/reject 选项,单独映射取消,并且绝不批准未知选项。外部或无调用标识的请求会委派;客户端 RPC 失败变为 `unavailable`。钩子和 `tools/pre-execute` 决定一次调用是否需要询问。该通道是自动化客户端与其 agent 之间的机器策略,不是 ACP 展示层。
|
||||
|
||||
应答者通过 [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md) 描述的桥精确 agent 归属检查进行路由,保留了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。
|
||||
应答者通过[仅面向自动化的 ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md)描述的桥精确 agent 归属检查进行路由,保留了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。
|
||||
|
||||
#### 审计,以及模型看到什么
|
||||
|
||||
@@ -135,5 +135,5 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有
|
||||
- `fs/write-intent` 门禁(`packages/fs/fs/`)——文档化的单占用决策槽 waterfall 语义(先到先得,通过 `next()` 委派),应答者契约复用了它。
|
||||
- `hook/invoked`/`hook/result`——仅日志审计对先例,`approval/asked`/`approval/decided` 沿用了它;[钩子桥 Agent Note](2026-06-30-hook-bridges.md) 交付了 `permissionDecision: ask`,即第一个生产者。
|
||||
- [拦截 seam Agent Note](2026-06-30-interception-seams.md)——`tools/pre-execute` 的 `allow`/`deny`/`ask` 词汇,本 seam 服务其中的 `ask`。
|
||||
- [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md)——应答者路由时对会话映射执行的精确 agent 归属检查;[多会话 Agent Note](2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。
|
||||
- [仅面向自动化的 ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md)——应答者路由时对会话映射执行的精确 agent 归属检查;[多会话 Agent Note](2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。
|
||||
- 机会性 `ctx.get()` 消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测)——`dsh-tools` 消费该 seam 而不阻塞其 fiber 的方式。
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
# Agent Note: Plan mode — a logged per-agent session mode
|
||||
|
||||
Status: implemented
|
||||
|
||||
> **Superseded vocabulary (2026-07-22):** [Collapse named session modes into plan mode](../simplification/2026-07-22-plan-specific-collaboration-state.md) replaces this note's generic `dsh-mode`, `mode/set`, definition map, and `ctx.modes` design with the current plan-specific `dsh-plan-mode`, `plan/mode`, `{ section }`, and `ctx.planMode` contract. The review, boundary, reconstructability, and sandbox-orthogonality decisions below remain in force; generic API examples are retained as the historical design this simplification removed.
|
||||
|
||||
> **Superseded ACP mapping:** [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) removes the picker, config-option, and elicitation mappings described below. Plan mode remains available to human-facing interfaces.
|
||||
|
||||
## Problem
|
||||
|
||||
Before this change, the harness had no durable way to put one agent into a distinct working stance. Plan mode needs the agent to explore and design under planning guidance, produce a reviewable artifact, cross an explicit approval boundary, and restore that state across resume and fork without making the model-visible request diverge from the session log.
|
||||
|
||||
The extension seams already supplied the surrounding pieces: [`system-prompt/assemble`](../../../../packages/core/system-prompt/README.md) shapes guidance per step and the shipped request is logged in `request/header*` events ([reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md)); [`ctx.userInteraction`](../../../../packages/ui/user-interaction/README.md) carries the approval question and corrective feedback ([ask-user precedent](../../implemented/feature/2026-06-25-ask-user-question.md)); `SessionEventMap` carries durable per-agent facts ([the `todo/write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)). The missing piece was the named session state that joins those seams while leaving execution enforcement on the independent sandbox and approval axes.
|
||||
|
||||
## Decision
|
||||
|
||||
The deliverable is **plan mode**. It ships as the first **session mode** — a named, logged, per-agent COLLABORATION state: a mode definition is deployment-configured guidance the model sees, while the mode IN FORCE for an agent is session state folded from its log. Modes are one axis and the enforcement knobs — the sandbox mode, the approval policy — are others: they never read or write each other, matching how Codex keeps its Plan/Default collaboration presets separate from its sandbox and approval settings. One new product package, `@deepseek-ai/dsh-mode` at `packages/mode/mode/`, owns the event vocabulary, a thin `ctx.modes` service, and every listener; the loop does not change. `plan` is the only required definition — the mode-shaped vocabulary exists so a second mode never renames durable event types, not because more modes ship now.
|
||||
|
||||
The state is one `SessionEventMap` member: **`mode/set`**, a log-only, non-surface event carrying `{ mode: string }` with whole-value-replace semantics, plus a pure `foldMode(events)` that returns the mode in force — the last `mode/set`, or the default mode when none exists. Because [the log is the fact channel](../../implemented/architecture/2026-06-30-event-domain-semantics.md), resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event`. The default mode is the absence of mode guidance — no section, filtering, or gate. Loading `dsh-mode` still contributes one stable `exit_plan_mode` schema in every mode; that fixed cost avoids tool-catalog churn at mode boundaries.
|
||||
|
||||
A mode's whole surface is soft: a `mode:policy` prompt section renders the active definition's guidance, while `exit_plan_mode` remains in the registered tool catalog across every mode and rejects at execution unless the folded mode is `plan`. A transition therefore changes only the system-prompt portion of the attributable `request/header` on the next step, keeping [reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) green without changing native schemas or Code Mode's SDK. A mode deliberately enforces NOTHING: no execution gate, no tool filtering, no reach into the sandbox or approval knobs — a user who wants a hard read-only floor while planning switches the sandbox-mode option beside the mode picker, in either order, and neither axis disturbs the other. There is likewise NO per-mode tool allow/deny list — which tools a mode admits is an effects question, parked until tool definitions declare their effects ([Deferred](#deferred)); a mode's restraint is its section's guidance plus the exit review.
|
||||
|
||||
The model leaves plan mode through the **`exit_plan_mode`** tool: its single argument is the plan text, which makes the plan reconstructable from the log, and the tool conducts the review itself through the user-interaction seam — a question whose supporting detail carries the exact plan, with options and a free-text channel, not a bare permission — so an approval flips the logged mode back to the default, and a rejection becomes the corrective error carrying the user's feedback verbatim, which keeps the model planning with direction. A user flips the mode from any surface through `ctx.modes.set()`; the flip is applied at the next turn boundary (session events are turn-enclosed) and narrated to the model once, only when the model-visible state actually changed.
|
||||
|
||||
## High-level API
|
||||
|
||||
### A plan-mode session end to end
|
||||
|
||||
The user switches the session to plan mode through the ACP mode picker or `/plan [message]` in a terminal front door, and from the next step every request ships the configured plan guidance section. When the optional message is present, that same command submits it into the affected step. The `exit_plan_mode` schema was already present in default and remains byte-identical.
|
||||
|
||||
The model explores and designs; the section's guidance is what defers changes into the plan. The sandbox and approval knobs keep whatever the user set them to — a deployment (or user) that wants kernel-enforced read-only during planning pairs plan mode with the independent sandbox-mode option.
|
||||
|
||||
When ready, the model calls `exit_plan_mode` with the plan markdown as its argument; the review question carries that exact markdown as supporting detail — approve, or keep planning, with free-text feedback welcome. A native call also renders the plan card; a Code Mode nested dispatch has no native card, so the review detail is the common presentation surface.
|
||||
|
||||
On approve, the tool flips the logged mode back to the default: the next step drops the plan section while retaining the same tool catalog (the changed header is in the log), and execution tracking from there is already `todo_write`'s job. On keep-planning, the model receives a corrective error carrying the user's feedback text, revises, and re-presents.
|
||||
|
||||
### Deployment configuration
|
||||
|
||||
Mode definitions are validated plugin Config — per repo convention, changeable from `cordis.yml` with no code edit. The deployment must provide the complete `plan` section; the package embeds no model instructions. Additional modes use the same config map:
|
||||
|
||||
```yaml
|
||||
- id: mode
|
||||
name: '@deepseek-ai/dsh-mode'
|
||||
config:
|
||||
modes:
|
||||
plan:
|
||||
section: |
|
||||
You are in plan mode: explore and design, then present the
|
||||
plan for approval through exit_plan_mode.
|
||||
```
|
||||
|
||||
A definition is exactly `{ section }` — there is deliberately no per-mode tool list and no enforcement field ([FAQ](#faq)). Definition names use the lowercase slash-command subset `/^[a-z][a-z0-9_-]*$/u`; `default` is reserved (the absence of policy) and rejected as a key. An invalid name or unknown definition key — a `tools` list or an `access` cap included — fails validation at load; an unknown mode name fails loudly at `set()` time.
|
||||
|
||||
### In the terminal
|
||||
|
||||
Terminal front doors get one entry command per configured definition through the plugin-owned command registry (`@deepseek-ai/dsh-commands`): `dsh-mode` registers `/plan [message]` for the required definition and, for example, `/review [message]` when `review` is configured. Each command records its named switch; a non-empty optional message is trimmed and passed to `agent.steer()`, which places it in a running agent's next step or delegates to `send()` for a new idle turn. The command name and result stay out of model history, while that explicit message is logged as an ordinary user message under the selected mode. The synthetic `default` entry contributes no command. The exit review prompts right in the terminal with no new machinery: it is an ordinary user-interaction question, so it rides the composed user-interaction provider's prompt queue that `ask_user_question` already uses.
|
||||
|
||||
### Over ACP
|
||||
|
||||
The mode PICKER is this package's surface: `session/new`/`session/load` advertise `availableModes`/`currentModeId` from `ctx.modes` (consumed opportunistically via `ctx.get`, the `tool-bash` pattern), `session/set_mode` calls `set()` and notifies `current_mode_update` optimistically (the pending mode IS the user's selection; the logged `mode/set` follows at the boundary), and a `session/event` listener re-notifies on each logged flip that differs from the last sent. The exit tool reuses the user-interaction ACP provider's elicitation flow; its ACP mapping carries the review `detail` because Code Mode nested dispatches have no native plan card, while native calls may additionally stream the plan card. Individual environment knobs — sandbox mode, approval policy, the model — are NOT modes and belong to `session/set_config_option` ([FAQ](#faq)).
|
||||
|
||||
### For agent creators
|
||||
|
||||
`ctx.modes` is the whole programmatic surface: `list()` returns the configured definitions plus the synthetic `default` entry (for pickers), `get(agent)` returns the folded mode plus any pending intent, and `set(agent, mode)` validates the name against `list()`'s vocabulary and records the boundary-applied intent — `default` is always a valid target, so exiting a mode is the same call as entering one. There is no creation-time mode option — a caller selects through `set()` before the first turn, which flushes identically. There is no live `agent/*` mirror to subscribe: UIs read `mode/set` off `session/event`, per [event-domain semantics](../../implemented/architecture/2026-06-30-event-domain-semantics.md).
|
||||
|
||||
## Detailed design
|
||||
|
||||
### Vocabulary
|
||||
|
||||
```text
|
||||
'mode/set': { mode: string } // SessionEventMap merge in dsh-mode: log-only, non-surface,
|
||||
// whole-value replace — the last one in the log wins
|
||||
DEFAULT_MODE = 'default' // the fold of a log with no mode/set; reserved, not definable
|
||||
```
|
||||
|
||||
The payload carries no reason/provenance field: a tool-driven flip sits next to its `tool/call` in the log and a user flip sits at its turn boundary, so the cause is log-adjacent — the same "narrative fields are derivable" call the [reconstructability Agent Note](../architecture/2026-07-05-reconstructable-requests.md) made for request-header facts (the in-flight `env/state` event carries a `source` precisely because its drift variant has NO log-adjacent cause — a contrast, not a conflict). Mode names are config-declared vocabulary, not opaque cross-boundary ids, so they stay bare strings (no `Branded<B>`).
|
||||
|
||||
### Config and the resolve step
|
||||
|
||||
```text
|
||||
interface ModeDefinition { section: string } // prompt text — a mode's whole vocabulary
|
||||
interface ModeConfig { modes: Record<string, ModeDefinition> } // plan is required and owns its complete prompt
|
||||
resolveConfig(config): ResolvedModes // explicit resolve (the dsh-bash template), fail-loud:
|
||||
// missing plan, 'default', blank sections, and unknown keys rejected
|
||||
```
|
||||
|
||||
The one-field shape is deliberate minimalism, not the final vocabulary: a per-tool policy dimension returns as effects metadata on tool definitions ([Deferred](#deferred)), read here rather than re-declared per mode — the config shape must not need a migration when it arrives.
|
||||
|
||||
### The fold, the service, and the flush
|
||||
|
||||
`foldMode(events)` is pure (exported for reconstructors and tests) and folds the append-only session log directly; `mode/set` is not a surface node, so compaction cannot shadow it. `set(agent, mode)` validates the name against `list()`'s vocabulary — the configured definitions plus the reserved `default`, which is rejected as a config KEY but always accepted as a `set()` TARGET — drops a no-op (target equals pending, else current), and otherwise records `{ mode, narrate }` in a `WeakMap` pending-intent slot. It cannot append immediately because [every session event is turn-enclosed](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) and an idle agent has no open turn.
|
||||
|
||||
Contained listeners on the loop's interception seams ([defensive patterns](../../../../docs/defensive-patterns.md): a policy plugin must not block a prompt or a turn) flush the pending intent as a `mode/set` append — `agent/prompt-submit` fires inside the just-opened turn before its first assembly, and `agent/turn-continuation` fires after an ordinary step closes before its successor. Automatic request recovery bypasses continuation, so a prepended `agent/request-error` wrapper delegates through the composed policy and asynchronous backoff, then flushes only a `retry` decision before the waterfall returns to the loop; an effect-scoped lifetime guard suppresses a captured wrapper that resumes after plugin disposal. All three paths sit outside tool execution and log publication (post-commit `session/event` observers are observe-only), so every step runs under the mode its assembly folded. When the flushed mode differs from the fold at the last `request/header`, the flush appends one coalesced `context/message` notice in the same frame ("The user switched this session to plan mode."); the user-visible narration cases are enumerated in the [FAQ](#faq).
|
||||
|
||||
### The soft layer: a computed section and a stable exit schema
|
||||
|
||||
The registered prompt section reads the calling agent's mode from `AssembleContext.agent` and resolves to the active definition's guidance or `''`. The loop renders per step and logs a complete `request/header` whenever the rendered header changes, so entering or leaving a mode is attributable. The section is static per mode and the plan itself stays in the conversation as messages and tool arguments; re-injecting separate plan state on every request ([Prior art](#prior-art)'s compaction-survival hack) is unnecessary prompt churn.
|
||||
|
||||
The guidance contribution is `{ name: 'mode:policy', order: 50, text: context => … }`: after persona (0), before tool guidance (100–199), and empty for default or agent-less assembly. `exit_plan_mode` is registered once through `ctx.tools` and never filtered, so native schemas and Code Mode's generated SDK remain byte-identical across mode switches; a deployment without `dsh-mode` lacks that one binding. There is NO `tools/pre-execute` listener: a mode gates nothing, while the exit tool's own folded-mode check rejects out-of-plan calls. The exit review is a question with options and feedback, not a permission, so it lives inside the tool's execution over the user-interaction seam.
|
||||
|
||||
### `exit_plan_mode`
|
||||
|
||||
`defineTool` has one required `plan: string` argument. Native execution records it in the ordinary `tool/call`; Code Mode records the outer `run_code` source before execution and appends the normalized nested arguments in `tool/code-dispatch` after the dispatch settles. `execute` rejects an agent-less call (the [`todo_write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)), rejects any folded mode other than `plan`, rejects an empty or heading-less plan before asking the reviewer, then conducts one single-select `ctx.userInteraction.ask()` review whose `detail` is the exact plan — approve or keep planning — with free-text feedback open. Only exactly one `Approve` selection consents; every other shape fails closed. Approval records a SILENT boundary-applied intent to switch to `default` and returns a short confirmation. The deployment guidance tells the model to make this the only and final tool call in its response; if a model violates that rule, the runtime still holds plan guidance for the rest of the batch, and the next step logs a changed header with the guidance removed and tool schemas unchanged. Every non-approval outcome returns a corrective `isError` and leaves the mode in `plan`.
|
||||
|
||||
Its [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md), decided up front: `presentCall` is a `generic` card titled by the plan's first heading with the plan markdown as content, plus a `generic` result card. Native front doors show that card before the question; Code Mode nested dispatches do not produce native call-card events, so the user-interaction `detail` independently carries the same plan on every provider. The seam is consumed opportunistically (`ctx.get('userInteraction')`), so `dsh-mode` composes without it and degrades to the manual exit pinned in the [FAQ](#faq).
|
||||
|
||||
### Dependencies and surfaces
|
||||
|
||||
`dsh-mode` is one product package, not a capability-seam trio ([Alternatives considered](#alternatives-considered)): it peers on `cordis`, `dsh-session`, `dsh-agent`, `dsh-tools`, and `dsh-system-prompt`, injects `['tools', 'systemPrompt']`, and reads `ctx.userInteraction` opportunistically at execute time (a type-only peer edge on `dsh-user-interaction`); its only UI-facing edges are optional type-only peers (`dsh-commands` for the per-definition entry commands). Beyond the `ctx.modes` call surface everything participates through listeners, so dropping the package gracefully removes modes rather than breaking a consumer. Terminal front doors need no mode-specific code: `dsh-mode` itself registers each definition's command on the command registry when one is composed (an optional type-only peer edge on `dsh-commands`), and the exit review rides the composed user-interaction provider's prompt queue. The ACP wire mapping is pinned in [High-level API](#over-acp); package-wise the bridge takes a type-only peer edge on `dsh-mode` and reads the service opportunistically, so a bridge without the plugin behaves exactly as today.
|
||||
|
||||
### The recorded scenario and the harness op
|
||||
|
||||
`input.json` gains one step op, `{ "op": "setMode", "modeId": "plan" }`, driven through the real `session/set_mode` RPC, and a scripted `elicitationAnswers` queue. The `plan-mode` scenario enters plan before turn 1, runs a real `cat` under the independently configured sandbox, presents a plan through `exit_plan_mode`, receives scripted approval, then edits on the next step. The first `request/header` contains the full stable toolset plus the configured mode section; the post-approval changed header retains byte-identical tool schemas and removes only that section. `plan-mode-reject` pins corrective free-text feedback and the unchanged plan state. Both recordings replay host commands under Seatbelt or bwrap; backend-specific sandbox denial stays at the bash-tool unit tier.
|
||||
|
||||
### The mechanical tail
|
||||
|
||||
No new cordis event is declared (`mode/set` rides `session/event`; the listeners attach to existing waterfalls), so the events catalog is untouched. Regenerated in the same change: the persistence log catalog (`mode/set`), the services catalog (`ctx.modes`, JSDoc-complete), the config catalog (`ModeConfig`), the tool catalog (`exit_plan_mode`), the producer/consumer map and doc graphs, and the module graph. Repo plumbing: a root tsconfig `paths` entry, the new group's README plus a [packages map](../../../../packages/README.md) row (a new top-level group is the deliberate act that table names), an `architecture.md` capability-services row for `ctx.modes` (budget-checked), and the cookbook row upgrade.
|
||||
|
||||
## Deferred
|
||||
|
||||
Each behind its own decision: subagent mode inheritance via a forwarded creation-time mode option (removed as unconsumed; it returns with its first consumer), preset modes beyond `plan` (read-only, accept-edits), the idle-record primitive if pending-intent loss proves real, and — the big one — **effects self-declaration on tool definitions**: a per-tool read-only/mutating classification (the MCP `ToolAnnotations` vocabulary — `readOnlyHint`/`destructiveHint` — is the natural template, with its untrusted-hint caveat implying trust tiers). That item is what a general per-mode tool policy waits on: this Agent Note first shipped an interim per-mode name allowlist and removed it before release — a hand-maintained list mislabels the effects question, must track every tool a deployment composes, and rots silently as tools arrive — so mode-scoped tool availability (and per-tool `ask` policies) returns as a CONSUMER of declared effects, which is its restart trigger.
|
||||
|
||||
The ACP automation composition does not mount plan mode or the question tool. Human-facing compositions own plan selection and review; focused plan-mode tests and interactive-interface snapshots pin its logged state, guidance, review, and stable tool schemas.
|
||||
|
||||
## FAQ
|
||||
|
||||
Behavioral clarifications of the chosen design; rejected designs live in [Alternatives considered](#alternatives-considered), accepted costs in [Consequences](#consequences).
|
||||
|
||||
**When does a user's mode flip take effect?** At the next pre-assembly boundary: `agent/prompt-submit` covers the first step, `agent/turn-continuation` covers a normal successor, and the post-composed `agent/request-error` retry decision covers automatic recovery. A mode selected while a request or retry backoff is in flight therefore shapes the following model request. This is the "applies to subsequent requests" semantics every product in [Prior art](#prior-art) ships.
|
||||
|
||||
**When is a mode change narrated to the model?** Only when the model-visible state actually changed: the flush compares the flushed mode against the fold at the last `request/header` and narrates once, coalesced. A net-zero flip sequence (plan then back, all before the boundary) narrates nothing; a tool-driven exit narrates through its own tool result instead; a mode set before the first turn narrates nothing — the section is the state statement. The principle is the in-flight env-state proposal's boundary narration: a silently flipped prompt surface leaves the transcript arguing from a state the header no longer has.
|
||||
|
||||
**What happens on resume when the config no longer defines the folded mode?** A folded mode name the current config no longer defines behaves as the default mode without a notice, so the session neither gains a substitute restriction nor becomes unusable. `set()`'s loud validation covers only the write path; a resumed log answers to the config it finds.
|
||||
|
||||
**What if a deployment composes no user-interaction provider?** Plan mode stays safe but manual: `ctx.userInteraction.ask()` throws `NO_PROVIDER` (and an absent seam never resolves at all), the tool returns the corrective `isError`, and the exit degrades to the user toggling modes — never to an unreviewed exit. The mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user in prose if that fails — so it keeps presenting instead of stalling.
|
||||
|
||||
**Why is there no per-mode tool allowlist?** Because "which tools are safe in a planning mode" is a property of each TOOL (its effects), not of the mode — a per-mode name list re-declares that fact in the wrong home, must enumerate every tool the deployment composes (MCP servers included), and rots silently as tools arrive. Until tool definitions declare their effects ([Deferred](#deferred), where the removed interim allowlist is archived with its restart trigger), a mode restrains by its section and the exit review; the exposure is an accepted cost ([Consequences](#consequences)).
|
||||
|
||||
**Do subagents inherit the parent's mode?** A fork child inherits for free — the parent's `mode/set` is inside the seeded prefix. A spawn child starts in the default mode; a creation-time mode option and automatic forwarding by subagent providers are deferred together ([Deferred](#deferred)).
|
||||
|
||||
**How does plan mode relate to the sandbox's read-only mode?** They are separate axes that never touch: the mode is the collaboration stance (a `mode/set` fold), the sandbox mode is an enforcement knob (a `bash/sandbox-mode` fold, [the sandbox Agent Note](2026-07-06-sandbox.md)) — plan mode neither reads nor caps it, exactly as Codex keeps its Plan/Default presets separate from its sandbox and approval settings. A user who wants kernel-enforced read-only while planning sets both: flip the mode picker AND the sandbox-mode option, in either order; each switch changes only its own fold, so there is no interference and no restore step to crash out of. The log attributes each axis to its own event — the stance to `mode/set`, the confinement to `bash/sandbox-mode`.
|
||||
|
||||
**Why aren't sandbox mode, approval policy, or the model themselves modes?** They are individual environment knobs independent of collaboration state. The retired ACP mapping is recorded by the [automation-only protocol decision](../simplification/2026-07-23-acp-automation-only-protocol.md). A mode definition may later bundle env facts (applied through `ctx.envState` where mounted) so a Codex-style preset stays a single mode; fusing approval policy into the mode CONCEPT itself is rejected in [Alternatives considered](#alternatives-considered).
|
||||
|
||||
## Prior art
|
||||
|
||||
A survey of shipped plan modes (Claude Code, Cursor, Copilot, OpenCode, Gemini CLI, Cline, Windsurf, Codex) shows the same five parts everywhere — the low-authority tool policy, plan artifact, approval moment, execution-state switch, and durable state that [Problem](#problem) builds on.
|
||||
|
||||
The mode surface is a LIST everywhere it is advertised, never a boolean: Claude Code's picker offers `plan` beside `acceptEdits` (plus an auto-mode entry into plan), and Codex exposes `Plan` beside `Default` as collaboration-mode presets while keeping approval and sandbox settings separate. The ACP transport does not advertise this human-facing control.
|
||||
|
||||
The deployment-owned example prompt borrows the instrumental behavior, not product-specific mechanics. From Codex: remain in plan mode despite imperative implementation language, explore before asking, distinguish repository facts from user-owned choices, and make the plan decision-complete across APIs, data flow, failures, tests, and assumptions. From Claude Code: prohibit mutations and commits, prefer existing patterns, use questions only for requirements or approach choices, and finish through the exit tool rather than a prose approval request. It deliberately omits Codex protocol tags and Claude's plan-file or phased-subagent machinery because those belong to their runtimes, not this plugin contract.
|
||||
|
||||
The ecosystems that leave modes to convention show the failure shapes to avoid. Pi-style mode extensions fight over a last-wins global active-tool list, enforce "read-only" by prompt text alone (a hallucinated call to a still-registered tool executes), and re-inject plan state into every request to survive compaction. The contested global list and the re-injection hack close structurally here — per-agent folded state, and a log-only non-surface event compaction cannot shadow. The prompt-only shape, by contrast, is deliberately KEPT — it is what Codex ships for Plan, and it is why the mode axis composes freely with the enforcement axes: a deployment that wants a hard floor pairs the mode with the independent sandbox knob instead of the mode carrying its own enforcement ([FAQ](#faq)).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Permission modes as the concept (the Claude Code shape).** One `permissionMode` fusing approval policy and tool policy. Here those are two axes with two owners: the approval seam owns "who answers this question", modes own "what surface does the model get". ACP models them as related but distinct (a mode may select an approval policy later — a mode definition gains a field, not a merger).
|
||||
|
||||
**A capability-seam trio.** Interface/implementation/consumer fits a swappable backend; a mode's variable parts are config values, not implementations. Splitting would manufacture an empty implementation package — the same "don't split preemptively" call the approval seam and [`todo/`](../../implemented/feature/2026-06-29-todo-write-tool.md) made.
|
||||
|
||||
**Loop-owned mode state.** Rejected on the standing rule (plugins, not loop changes): every hook the feature needs — assemble, pre-execute, turn boundaries, session events — is already a documented seam, so a loop edit would buy nothing but coupling.
|
||||
|
||||
**A per-mode tool allowlist with a deny-by-default gate (the first shipped shape).** Removed before release. A hand-maintained name list re-declares a per-TOOL fact (its effects) per MODE: it must enumerate every tool the deployment composes — MCP servers and future registrations included — and it rots silently as tools arrive (a new read-only tool is blocked until someone edits every mode; the author burden lands on whoever knows the mode, not whoever knows the tool). It also over-promises: the list looks like a security boundary while the real boundary for anything non-shell does not exist. The general dimension is parked on effects self-declaration ([Deferred](#deferred)); the consequence — plan mode is guidance-only, the very Pi hole the gate once closed — is accepted deliberately, priced in [Consequences](#consequences).
|
||||
|
||||
**An `access` sandbox cap on the mode (the second shipped shape).** Also removed before release. `ModeDefinition.access` clamped the bash seam's per-call sandbox resolution to a mode-declared ceiling (a `bash/resolve-mode` waterfall + ladder-min listener, with guards withholding bash under an unconfinable executor and denying escalation mid-mode). The state stayed orthogonal — the clamp never wrote the sandbox knob — but the AXES did not: entering plan changed what the sandbox enforced, fusing the collaboration stance with an enforcement level and contradicting the Codex-shaped separation the review converged on (Plan/Default presets never touch sandbox or approval settings). One user-visible symptom of the fusion: flipping the sandbox option to `workspace-write` while planning silently did nothing. The cap, the waterfall, and the mode→bash dependency edge were removed together; a deployment gets kernel-enforced read-only planning by pairing the mode with the independent sandbox-mode option, and a mode-triggered PRESET (a mode definition bundling suggested knob values, applied as ordinary knob switches) can return later without re-fusing the axes.
|
||||
|
||||
**Runtime-only mode (UI- or bridge-local, unlogged).** Resume and fork would silently drop the mode, and the header deltas a mode causes would have no attributable cause in the log. Logged state is what makes the mode auditable and restorable for free.
|
||||
|
||||
**Mode flips as `context/message` via `agent.inject()`.** Reuses an existing turn-enclosure path, but puts policy state into the model transcript — the model does not need to be told twice (the section already tells it), and a log-only fact should not occupy surface.
|
||||
|
||||
**A plan-file store (`.plans/` directory).** A second durable home for what the log already carries replayably; a deployment wanting files can add a tool that writes them. One home per fact.
|
||||
|
||||
**A boolean `planMode` instead of named modes.** Too narrow for the surface the repo already tracks: ACP advertises a mode LIST and the shipped pickers fill it with more than plan ([Prior art](#prior-art)); generalizing later would rename durable event vocabulary. The string-shaped mechanism costs nothing extra now; only `plan` ships as a definition.
|
||||
|
||||
**A tool-policy-stack service (the Pi-critique remedy).** A dedicated composition service for tool policies is premature: this implementation performs no mode-scoped tool filtering, and future effect policies can compose through the existing guarded execution seams. Formalize only when declared tool effects create a concrete composition requirement.
|
||||
|
||||
**Exit approval through the approval seam (a `{ kind: 'ask' }` gate decision).** The original sketch, natural while the approval seam was the only asking machinery in flight — but it seats a review in a permission chair: the seam's outcome vocabulary is deliberately closed and one-shot (`allowed-once`/`rejected`), so a rejection carries no feedback and an approval can never grow options (approve-and-accept-edits). The exit moment is a question, not a permission — the user-interaction seam gives it options plus the free-text channel, and the rejection feedback reaches the model verbatim. The approval seam remains the right seat for genuine permission gates (the sandbox escalation), and the registry's `ask` vocabulary stays available to deployments that want one there.
|
||||
|
||||
**Exit by prose or steering instead of a tool.** No artifact and no approval moment — the tool's argument IS the reviewable plan, and its review question is what gives the human a structured yes/no attached to the exact transition.
|
||||
|
||||
## Consequences
|
||||
|
||||
What holds now, pinned by the unit, protocol, snapshot, and real-API tiers:
|
||||
|
||||
- The mode in force is a pure function of the session log: resume and fork restore it with no extra machinery, and a `mode/set` is followed by a matching complete `request/header` on the next changed step.
|
||||
- A user-driven flip narrates exactly once at the next boundary and a net-zero flip sequence narrates nothing; a tool-driven exit narrates only through its tool result.
|
||||
- In default mode the plugin contributes no mode section but does contribute the stable `exit_plan_mode` schema; a deployment without `dsh-mode` lacks that binding.
|
||||
- Native tool schemas and Code Mode's SDK stay byte-identical across default, plan, and custom-mode transitions; only the configured guidance section changes.
|
||||
- Plan mode changes nothing on the enforcement axes: the toolset, the sandbox mode, escalation, and the approval policy behave identically in plan and default — pairing the mode with the independent sandbox/approval knobs is how a deployment hardens planning.
|
||||
- Mode definitions are changeable from `cordis.yml` with no code edit; the complete plan instructions are required there, while missing plan config, malformed definitions, and unknown keys fail at load and unknown mode names fail at `set()`.
|
||||
- `exit_plan_mode` is always advertised, rejects outside plan, drops only plan guidance after approval, and carries keep-planning feedback in a corrective `isError`; each human-facing surface's user-interaction provider carries the review.
|
||||
- The docs tail shipped with the landing: READMEs, regenerated catalogs (persistence log, config, cordis services, tools), the packages map and architecture rows, and the cookbook row.
|
||||
|
||||
The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). A mode transition changes the system prompt at order 50, so the cache path from that point onward changes, but the tool schemas and Code Mode SDK no longer churn. **A mode restrains by guidance alone**: a model that ignores the section CAN mutate during plan — the review moment, the session log, and independent sandbox, approval, and filesystem policies are the containment surface. Hardening planning means setting those knobs, not widening the mode; the removed enforcement shapes and their effects-declaration restart trigger remain in [Alternatives considered](#alternatives-considered) and [Deferred](#deferred). Human-facing interfaces own the plan picker and review interaction; the ACP automation transport carries neither.
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-14-acp-agent-client-protocol.md: da23bbfa247bc2423072477cc4b6277485df1c9c
|
||||
2026-06-14-acp-agent-client-protocol.zh.md: ec55922e0aee57169d8bbf44c3d91b18ea83041e
|
||||
2026-07-08-background-subagent-tasks.md: 12b34f2a28cfa311a48904cd5396ec16d9123641
|
||||
2026-07-08-background-subagent-tasks.zh.md: 58f035a14d55bc0b4e7f670111909074c3f53b2b
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-08-background-subagent-tasks.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [subagent seam](2026-06-21-subagent-capability-seam.md) returns a `SubagentRun`, but the model-facing tool originally collected every run synchronously. Independent, slow delegations therefore held the parent call open or ran serially.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Agent Note: 后台 subagent 任务
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-08-background-subagent-tasks.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[subagent seam](2026-06-21-subagent-capability-seam.md) 会返回 `SubagentRun`,但原先面向模型的工具会同步收集每一次运行。因此,各自独立的慢速委派要么一直占用父调用,要么按串行方式运行。
|
||||
|
||||
subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案和任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放契约。
|
||||
|
||||
## 决策
|
||||
|
||||
每个 `dsh-tool-subagent` 实例都可以公开 `run_in_background`,由 `enableRunInBackground` 控制,且默认启用。禁用该功能的实例不包含此参数,并会在执行时拒绝强制传入的后台参数。提供方选择仍属于部署配置,因此一个实例仍然只为一个提供方注册一个名称可区分的工具。
|
||||
|
||||
后台 subagent 使用[通用后台任务运行时](../architecture/2026-06-20-generic-long-running-tool-runtime.md)。`task_output`、`task_list` 和 `task_kill` 负责收集、列出、取消、完成通知和提示词引导;系统不提供 subagent 专用的配套工具。
|
||||
|
||||
前台调用保留其同步契约:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果;并且始终在返回前释放该运行。
|
||||
|
||||
对于后台调用,工具会验证父级,并在调用 `ctx.tasks.start()` 前拒绝已中止的执行信号。任务运行时会在调用生产者启动器前,预检控制接口和拥有者清理。该启动器创建独立的 `AbortController` 并启动 `ctx.subagents.start()`;返回 id 之后,工具调用的信号不再拥有该子级。
|
||||
|
||||
任务注册按以下方式映射 subagent seam:
|
||||
|
||||
- `kind` 为 `subagent`,`label` 为模型提供的描述,`owner` 为父 agent(智能体)。
|
||||
- `cancel(reason?)` 中止任务自有的控制器。同一个信号同时覆盖尚未完成的提供方启动和已就绪的子级。
|
||||
- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。
|
||||
- `readOutput` 不存在。任务存活期间,`task_output` 只返回状态;结算后,它以幂等方式返回最终输出。中间的子级活动仍保留在子会话中。
|
||||
|
||||
## 生命周期
|
||||
|
||||
后台 subagent 归属于其父 agent,不会在拥有者关闭后持久存续。任务运行时将清理附加到对应拥有者的确切作用域。agent 资源释放会取消任务,并在 `AgentHandle.dispose()` 完成前等待启动回滚或子级资源释放,避免泄漏子 agent 和会话。
|
||||
|
||||
完成通知会发送给启动时捕获的确切拥有者。如果拥有者清理过程已经释放了注入目标,该通知将被丢弃;生命周期保证是清理,而不是通知。
|
||||
|
||||
## 模型引导
|
||||
|
||||
通用任务提示词教会模型一套共享的做法:保留 id;继续独立工作,而不是忙等轮询;在回答前收集相关任务;终止无关工作。subagent schema 只补充说明:后台模式返回 task id,且 `task_output` 用于收集结果。无论模型是否遵循提示词,授权和拥有者清理都会强制执行运行时边界。
|
||||
|
||||
## 备选方案
|
||||
|
||||
### subagent 专用的等待、输出和停止工具
|
||||
|
||||
能力专用工具会重复任务协议,再教一套收集与停止习惯,并增加多个提供方实例的复杂度。通用运行时在不改变工具「每个实例对应一个提供方」形态的前提下,提供了所需行为。
|
||||
|
||||
### 在拥有者关闭后存续
|
||||
|
||||
该方案需要持久化的任务状态、子会话恢复、延迟结果交付通道,以及对被遗弃拥有者的处理策略。以拥有者为作用域的清理为进程内工作界定了明确生命周期。持久作业需要单独设计。
|
||||
|
||||
### 隔离客户端不做拥有者检查
|
||||
|
||||
agent 和日志可能以会话为作用域,但任务注册表和可预测 id 属于运行时全局范围。因此,通用拥有者防线同样适用于 subagent 和所有其他生产者。
|
||||
|
||||
### 增量子 transcript 输出
|
||||
|
||||
将子级历史以流式方式写入父级,会模糊日志边界,并使提供方行为分化。此接口只公开最终输出;更丰富的观察应由会话或 UI 工具承担。
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试覆盖固定了停止原因映射、在报告前释放资源、启动与结果失败、对预中止的拒绝、从启动调用信号分离、在提供方就绪前后取消、通过真实任务工具收集、无控制接口的预检防线、运行时缺失失败,以及每实例 schema 开关。快照覆盖固定了面向模型的 schema。
|
||||
|
||||
## 影响
|
||||
|
||||
父级可以并行分派慢速委派任务,并通过与 bash 共用的任务控制来收集结果。子级工作不再占用启动它的工具调用,但在收集、终止或拥有者释放之前可以继续消耗资源。提示词引导鼓励收集;拥有者清理则提供硬性生命周期边界。需要同步委派的部署可以按工具实例禁用后台模式。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-06-18-acp-terminal-and-tool-rendering.md: e8426dbf1a0e3e4f9d9857baa19945cee6eee4b3
|
||||
2026-06-18-acp-terminal-and-tool-rendering.zh.md: ff269e002a0ecea8c0bacf53fe85e553a6b9b9d5
|
||||
2026-07-09-bash-backed-grep-glob-discovery.md: 9c25afb44885ca2519c5d74a3d721b34fe3561de
|
||||
2026-07-09-bash-backed-grep-glob-discovery.zh.md: d0dee8e49e0a500c87afbd59423fb65416c1dcc8
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-09-bash-backed-grep-glob-discovery.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness needs model-facing `glob` and `grep` tools, but making them `ctx.fs` provider methods turns a local product convenience into a universal filesystem backend contract. Local workspace discovery is naturally a process-backed `rg` workflow; remote or virtual filesystem backends may expose their own search API, may not share a local `ripgrep` view, or may not support discovery at all. The v1 should not require every filesystem backend to implement search before the file read/write/edit seam has proven that need.
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Agent Note: 由 Bash 支持的 grep 与 glob 发现工具
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-09-bash-backed-grep-glob-discovery.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 需要面向模型的 `glob` 和 `grep` 工具,但如果将它们实现为 `ctx.fs` 提供方的方法,就会把本地产品便利功能变成所有文件系统后端都必须实现的契约。本地工作区发现天然适合由进程支持的 `rg` 工作流;远程或虚拟文件系统后端可能公开自己的搜索 API,可能无法共享本地 `ripgrep` 视图,也可能完全不支持发现。文件读取/写入/编辑 seam 尚未证明此需求前,v1 不应要求每个文件系统后端都实现搜索。
|
||||
|
||||
搜索输出还受到两层不同的预算约束。工具需要足够多的原始 `rg` 输出,才能计算稳定的逻辑结果;模型则只能收到有界预览,并在格式化结果超出内联预算时获得恢复路径。通用落盘策略只能看到最终工具结果,因此无法恢复搜索工具已经省略的匹配项。搜索工具必须自行负责保留,并尽力落盘格式化结果。
|
||||
|
||||
## 决策
|
||||
|
||||
`glob` 和 `grep` 是 `@deepseek-ai/dsh-tool-fs-search` 中的条件式面向模型工具,由 bash seam 支持,不会成为新的 `ctx.fs` 提供方方法。加载插件时,该包(package)执行 `command -v rg >/dev/null 2>&1`:先通过 `ctx.bash.resolve(request)` 解析请求,再通过 `ctx.bash.run(spec)` 运行;如果命令以非零状态退出,该包会记录警告,并且既不注册工具,也不注册提示词章节。如果探针无法启动、超时、中止、被终止,或没有产生退出码,插件加载会明确失败,因为这意味着 bash 执行器损坏,而不是可选二进制文件缺失。注册后,执行流程同样依次调用 `ctx.bash.resolve(request)` 与 `ctx.bash.run(spec)`,并使用工具组装的固定 `rg` 命令模板。工具层负责 schema、参数验证、shell 引用、结果解析、结果格式化、保留、格式化结果落盘交接,以及超时声明。bash 执行器负责请求默认值解析与上限控制、子进程执行、进程组终止、环境清理、原始输出捕获,以及在本地、沙箱或远程 bash 实现之间替换后端。
|
||||
|
||||
这些工具不使用 `ctx.bash.start()`,也不创建模型可见的后台任务。从 agent loop(智能体循环)的视角看,它们是普通前台工具:只有当 `rg` 命令退出、超时、中止或失败后,工具调用才返回。`defineTool({ timeoutMs })` 声明协作式工具调用预算,`@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;工具会在 `resolve()`/`run()` 前将该信号转发给 bash 请求。bash 后端自身的超时仍作为第二层安全上限;先触发的中止生效。
|
||||
|
||||
这些工具使 `path` 与 Claude Code 的搜索工具保持一致,但将解析绑定到 bash workdir,而不是 `ctx.fs`。工具从 `exec.agent?.session.header.cwd` 派生 bash 请求 workdir,与 `dsh-tool-bash` 和 `dsh-tool-fs` 一致;如果会话没有 cwd,它会省略 `request.workdir`,由 bash 实现通过 `resolve()` 应用其配置的 cwd 或进程 cwd。对于 `grep`,`path` 是可选的 ripgrep 目标,可以是文件或目录;省略时使用已解析的 bash workdir。对于 `glob`,`path` 是可选的目录搜索根;省略时同样使用已解析的 bash workdir。相对 `path` 值基于该 workdir 解析。只要可行,返回路径就会显示为相对于已解析 bash workdir 的形式;只有在共置部署中,bash workdir 与文件系统 `read` 根指向同一个工作区时,这些路径才保证可以继续读取。v1 会记录这项部署要求,但不执行跨服务运行时验证。在形成共享工作区/根契约或提供方专用搜索后端之前,远程或虚拟文件系统搜索保持暂缓。
|
||||
|
||||
该包不注入 `fs`,而是注入 `tools`、`systemPrompt` 和 `bash`;它有意读取 `spillStore` 时使用 `ctx.get('spillStore')`,而不使用静态注入,因为格式化结果落盘是可选功能。现有 `@deepseek-ai/dsh-tool-fs` 部署若只需要 `read`/`write`/`edit`,则无需加载 bash。加载搜索功能的部署则必须让 bash 执行器环境可以使用 `rg`,这些工具才会进入模型可见 schema。
|
||||
|
||||
### 包结构
|
||||
|
||||
v1 包保持精简。`@deepseek-ai/dsh-tool-fs-search` 内部的源代码布局如下:
|
||||
|
||||
```text
|
||||
src/index.ts
|
||||
src/glob.ts
|
||||
src/grep.ts
|
||||
src/search-core.ts
|
||||
src/shell-quote.ts
|
||||
```
|
||||
|
||||
`glob.ts` 和 `grep.ts` 各自负责参数验证、命令构造、结果解析、格式化和注册。`shell-quote.ts` 是一个共享辅助模块,因为 shell 引用是两个工具都必须经过的安全边界;`search-core.ts` 是另一个共享模块(实现时对原四文件方案所作的修订):`SEARCH_*` 错误词汇、bash 运行与原始输出获取、格式化结果落盘交接,以及 workdir 相对显示,在两个工具中完全相同。若在每个工具中重复这套精细管道,正是对称性约定所指出的漏提取问题。命令构造器禁止自行拼凑引用,也不能把未经引用、由模型控制的值直接连接到 shell 命令中。
|
||||
|
||||
### Schema 与配置
|
||||
|
||||
`glob` 公开精简的发现形状:
|
||||
|
||||
```ts
|
||||
interface GlobArgs {
|
||||
pattern: string
|
||||
path?: string
|
||||
}
|
||||
```
|
||||
|
||||
`grep` 公开 OpenCode 风格的最小形状:
|
||||
|
||||
```ts
|
||||
interface GrepArgs {
|
||||
pattern: string
|
||||
path?: string
|
||||
include?: string
|
||||
}
|
||||
```
|
||||
|
||||
常规预算不会进入面向模型的 schema。`@deepseek-ai/dsh-tool-fs-search` 拥有以下带默认值并经过验证的配置字段:
|
||||
|
||||
| 字段 | 默认值 | 作用 |
|
||||
|---|---:|---|
|
||||
| `globMaxResults` | `100` | 内联保留的最大路径数;与 Claude Code 的默认 `GlobTool` 结果上限一致。 |
|
||||
| `grepMaxMatches` | `250` | 内联保留的最大扁平匹配数;与 Claude Code 的默认 `GrepTool` `head_limit` 一致。 |
|
||||
| `grepMaxLineBytes` | `2000` | 每条匹配行预览保留的最大字节数,通过 `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })` 应用。 |
|
||||
| `rawOutputMaxBytes` | `20000000` | 工具会解析的完整原始 `rg` stdout 最大字节数;与 Claude Code 的 ripgrep 原始缓冲区一致。 |
|
||||
| `timeoutMs` | `30000` | 附加到两个工具定义并由 `@deepseek-ai/dsh-timeout-policy` 强制执行的工具调用超时。 |
|
||||
|
||||
`globMaxResults` 和 `grepMaxMatches` 使用 `ItemRetainer({ kind: 'head' })`。`grepMaxLineBytes` 针对每条匹配行使用 `TextRetainer({ kind: 'head', maxBytes: grepMaxLineBytes })`,使预览截断保留 UTF-8 边界。这遵循[工具结果保留库](../architecture/2026-07-06-tool-result-retention-library.md)对发现条目的映射:收集完整结果,在内联结果中保留头部条目,并将路径映射、分组和逐行预览放在保留器外部。v1 的 `grep` 不公开 `case_insensitive`、`head_limit`、`offset`、`count`、多行、上下文行、输出模式或文件类型过滤器。模型如需周边上下文,可使用 `read` 读取匹配文件;如需后续结果,则遵循返回的落盘定位符所给出的检索提示。
|
||||
|
||||
Claude Code 的数值只是两层预算的参考点,并非面向模型 schema 的先例。其专用搜索工具会缓冲最多 20 MB 的原始 ripgrep 输出用于内部处理;在非 WSL 平台上使用 20 秒 ripgrep 超时,在 WSL 上使用 60 秒,之后才在模型看到结果前应用搜索专用上限:`GrepTool` 默认 `head_limit = 250`,并持久化超过 20,000 个字符的格式化结果;`GlobTool` 默认最多 100 条路径,并持久化超过 100,000 个字符的格式化结果。此 Agent Note 采用同样的原始缓冲区和内联数量默认值,将默认搜索超时设为 30 秒,并通过本 harness 的 `ctx.spillStore.saveText()` 路径恢复格式化结果。
|
||||
|
||||
`path` 字段沿用与 Claude Code 相同的区分方式:`grep.path` 是文件或目录形式的 ripgrep 目标,`glob.path` 则是目录搜索根。v1 不为这些工具公开单独的 cwd/workdir 参数。
|
||||
|
||||
`include` 是一个正向 glob 过滤器,不是列表,也不是排除语法。系统会预先拒绝逗号分隔或取反的 include 模式,并返回结构化参数错误。shell 命令中使用的每个模型控制值,包括 `pattern`、`path` 和 `include`,都必须经过包私有的 shell 引用辅助模块。
|
||||
|
||||
### 执行
|
||||
|
||||
`glob` 构建固定的 `rg --files` 命令,并以解析后的目录搜索根为根(提供 `path` 时使用该值,否则使用 bash workdir):`rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,另加针对 `.git`、`.svn`、`.hg`、`.bzr`、`.jj` 和 `.sl` 的 VCS 元数据排除项。这样既与 Claude Code 的隐藏/忽略文件发现和修改时间排序保持一致,也避免宽泛搜索包含 VCS 内部文件。工具逐行解析路径,只要可行就将结果映射为相对于 bash workdir 的路径,将每条路径推入 `ItemRetainer({ kind: 'head', maxItems: globMaxResults })`;当保留结果达到上限时,它会格式化完整的已排序路径列表,作为落盘产物。
|
||||
|
||||
`grep` 构建固定的逐行 `rg --json` 命令,作用于所提供的文件/目录目标(提供 `path` 时使用该值,否则使用 bash workdir),从而无需按冒号拆分,就能解析文件路径、行号和行文本。它消费 `match` 记录,将格式错误的 JSON 或匹配记录视为 `SEARCH_FAILED`;只要可行,就将结果路径映射为相对于 bash workdir 的路径;通过 `grepMaxLineBytes` 应用逐行预览保留,将每个匹配推入 `ItemRetainer({ kind: 'head', maxItems: grepMaxMatches })`,然后只按文件分组内联输出中保留的预览匹配。落盘产物保存完整的格式化匹配列表,而不是只保存省略的尾部,因此检索提示指向模型已经看到的同一逻辑结果。
|
||||
|
||||
原始 `rg` stdout 是内部传输细节。工具请求 `stdoutMaxBytes: rawOutputMaxBytes`,并通过 `ctx.bash.resolve()` 解析;只有当执行器在该上限内返回未截断的 stdout 时,工具才解析 `stdout.text`。如果 stdout 超过 `rawOutputMaxBytes`,或者执行器仍返回 `stdout.truncated`,工具会以明确的搜索错误失败,要求模型缩小 `pattern`、`path` 或 `include`。工具绝不向模型公开原始 `rg` 输出或 bash 原始落盘路径。
|
||||
|
||||
只有 stdout 是解析源。对于无效模式、注册后运行时 `rg` 消失,以及搜索失败,stderr 作为诊断文本;如果 bash 截断 stderr,工具会使用保留的 stderr 尾部并附加截断说明,不会读取 `stderr.spillPath`。
|
||||
|
||||
如果 `ctx.bash.run()` 因工具超时或调用方取消触发而报告 `aborted`,工具会返回结构化失败,而不是假装没有匹配项。如果 bash 自身的超时先触发,工具同样会以明确的超时消息失败。非零 ripgrep 退出语义由工具负责:退出码 0 表示存在匹配并成功;退出码 1 表示没有匹配但成功;无效模式、运行时 `rg` 消失或无法访问搜索 workdir 则表示失败。
|
||||
|
||||
搜索失败使用包自有的 `HarnessError` 子类与 `SEARCH_*` 代码,而不使用 `FsErrorCode`,因为这些工具不是 `ctx.fs` 提供方操作。v1 的词汇包括 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 和 `SEARCH_ABORTED`。缺少必填字段、空字符串或不支持的取反/列表式 `include` 值等模型参数验证失败,仍作为普通工具参数错误处理。
|
||||
|
||||
### 格式化结果落盘
|
||||
|
||||
`ctx.spillStore` 是可选服务,仅用于面向模型的格式化结果。这是代码库中首个工具自有落盘调用模式;此设计有意为之,因为搜索保留属于条目级策略:`globMaxResults` 限制路径数,`grepMaxMatches` 限制匹配数,而工具此时仍持有完整逻辑结果。通用 `dsh-spill-policy` 会在 `tools/post-execute` 阶段限制最终文本字节数;到那时搜索工具已经省略后续路径或匹配,策略无法恢复它们。
|
||||
|
||||
当搜索产生的逻辑结果数超过内联上限,且 `ctx.spillStore` 存在时,工具会通过 `saveText()` 保存完整的格式化结果。落盘所有者是调用 agent 的会话头 id(`exec.agent?.session.header.id`);缺少该所有者时,搜索会保留内联结果,并报告完整结果无法保存。落盘来源是工具执行身份:`{ toolName: exec.name, callId: exec.callId, label: 'result' }`。建议文件名为 `grep-results.txt` 和 `glob-results.txt`;落盘后端仍将它们视为提示,而不是路径。
|
||||
|
||||
如果落盘存储不存在、调用没有会话所有者,或保存失败,工具仍返回内联页和页脚,说明完整结果无法保存。格式化结果落盘存储不可用本身绝不能把搜索成功变为 `isError` 结果。
|
||||
|
||||
bash 原始输出流与格式化搜索落盘产物是两个不同的产物。原始 `rg` stdout 只会在所请求的 bash stdout 上限内于内存中解析;格式化落盘产物则是 `ctx.spillStore.saveText()` 生成的稳定、面向模型的恢复定位符。
|
||||
|
||||
### 结果形状
|
||||
|
||||
带有成功格式化落盘的受限 `glob` 结果会返回内联页与落盘通知:
|
||||
|
||||
```text
|
||||
<first N paths>
|
||||
|
||||
(Showing N of M paths. Full sorted result stored at: /.../session-abc123/9f8e7d-glob-results.txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
带有成功格式化落盘的受限 `grep` 结果会返回分组后的预览匹配与落盘通知:
|
||||
|
||||
```text
|
||||
Found N of M matches
|
||||
|
||||
<file>
|
||||
Line 12: ...
|
||||
|
||||
(Full grep result stored at: /.../session-abc123/9f8e7d-grep-results.txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
如果完整逻辑结果未超过内联上限,系统不会创建格式化落盘产物。如果完整逻辑结果过大但无法格式化落盘,页脚会说明结果已受限,完整结果无法保存。`truncated`/省略计数是预算事实,并不表示搜索不完整;超时、无效正则表达式、运行时 `rg` 消失、无法访问 workdir、原始输出溢出、跳过二进制文件和解析失败,仍属于工具领域的错误或不完整字段。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**将 `glob`/`grep` 放在 `ctx.fs` 上。** v1 不采用:这会迫使每个文件系统后端增加搜索 API,并使本地 ripgrep 行为成为提供方 seam 的一部分。搜索是有用的产品行为,但不像 `readText` 或 `writeText` 那样属于通用文本存储原语。
|
||||
|
||||
**直接从 `dsh-fs-local` spawn ripgrep。** 此 Agent Note 的 v1 不采用:直接 spawn 提供最简洁的 argv 边界、stdout/stderr 控制与提前停止控制,但会重复 bash seam 已负责的进程执行事项,包括环境清理、进程组终止、超时传播、沙箱/远程执行器替换,以及有界输出捕获。如果 bash 支持的搜索被证明过于依赖 shell 字符串,或必须支持前台流式输出,该方案仍是合理优化。
|
||||
|
||||
**通过 `ctx.bash.start()` 实现流式提前停止。** 不采用:`start()` 会创建模型可见的后台任务语义,包括 task id、所有者 token、`bash_output`、`bash_kill`、完成通知,并且没有内置超时。`grep` 需要前台工具结果,而不是后台 bash 工作流。如果将来必须流式搜索,正确的抽象是在 bash/进程 seam 上增加前台流式进程句柄,而不是借用公开后台任务 API。
|
||||
|
||||
**向模型公开 bash 原始落盘路径。** 不采用:bash 原始落盘路径包含原始 `rg` stdout(对于 grep 即 `rg --json` 记录),并非稳定的格式化搜索结果。搜索只把原始 stdout 当作内部传输;模型恢复使用通过 `ctx.spillStore.saveText()` 保存的格式化结果。
|
||||
|
||||
**先为 bash 输出规范化增加 `spillStore.saveFile()`。** 此 Agent Note 的 v1 不采用:未来规范化 bash 时,`saveFile()` 可以帮助将现有执行器落盘文件移动到会话范围的落盘存储,但搜索只需在生成面向模型的产物前,在内存中获取有界的原始 `rg` stdout。`saveText()` 足以保存格式化搜索结果。
|
||||
|
||||
**依赖通用 `dsh-spill-policy`。** 不采用:通用 post-execute 落盘只能看到最终工具结果。如果 `grep`/`glob` 内联返回第一页,通用策略无法恢复省略的结果。搜索工具必须在返回有界的面向模型文本前,自行保存完整的格式化结果。
|
||||
|
||||
**公开 Claude Code 的完整 `GrepTool` schema。** v1 不采用:`output_mode`、上下文标志、多行、`head_limit`、`offset`、`case_insensitive` 和类型过滤器会使面向模型的接口变成 ripgrep 包装层。本 harness 将常规预算与续传机制保留在部署策略和落盘产物中。
|
||||
|
||||
**保留提前停止搜索,并省略格式化落盘产物。** 此提案不采用:提前停止效率更高,却不给模型检查后续结果的路径。所选 v1 优先保证结果可恢复性与实现简洁性,并以 `timeoutMs`、`rawOutputMaxBytes`、bash 后端上限和格式化落盘产物作为安全后备。
|
||||
|
||||
**先扩展 bash seam,增加原始输出读取器。** 不采用:可移植的 `readRawOutput(ref, maxBytes)` API 会增加引用生命周期、权限和后端存储语义。逐次运行的 `stdoutMaxBytes` 请求是更窄的 seam:搜索要么在 `rawOutputMaxBytes` 内收到完整 stdout,要么明确失败。
|
||||
|
||||
**始终注册,只有执行时才报告缺少 `rg`。** 不采用:模型可见工具 schema 是部署能够尝试该能力的承诺。如果 bash 执行器在加载时找不到 ripgrep,更安全的接口是完全没有 `glob`/`grep` 工具或提示词指引。对于注册后发生环境变化的情况,执行时的 `rg` 缺失分类仍作为防御性回退。
|
||||
|
||||
## 测试
|
||||
|
||||
- 测试覆盖注册时 `rg` 探测(探测成功会注册两个工具和提示词章节;非零探测会跳过工具与提示词章节并发出警告;基础设施探测失败会拒绝插件加载),证明中止的 `exec.signal` 会到达 bash 后端(通过同一引用的 spec 断言和 `SEARCH_ABORTED` 结果),并覆盖命令构造/引用(恶意模式、带空格路径、以短横线开头的值、引号、换行、glob 元字符:既有单元断言,也针对每个恶意值执行真实 `bash -c` 往返)、将 `grep.path` 用作文件与目录目标、将 `glob.path` 用作目录搜索根、无效模式处理、无匹配、格式错误的 `rg --json` 输出、匹配行预览截断、原始输出溢出、超时/中止、格式化落盘成功/失败、包自有 `SEARCH_*` 错误代码,以及无后台任务不变量。
|
||||
- 直接覆盖第一方工具自有落盘先例:落盘后端存在、落盘后端缺失、`saveText()` 失败,以及缺少落盘所有者。
|
||||
- 该包通过真实 Loader 路径覆盖命名空间插件的导出形状(`name`、`inject`、`Config` 和 `apply`,且没有默认导出)。
|
||||
- 真实执行器集成测试(`dsh-bash-local` + 真实 `rg`)验证外部世界:恶意模式保持惰性、逐会话 cwd 解析、VCS 元数据排除、按修改时间排序,以及真实 ripgrep stderr 分类。如果测试进程的 PATH 中没有 `rg`,该测试会自行跳过(这是与无密钥 e2e 跳过相似的 CI 兼容措施);伪执行器测试覆盖注册和执行时缺少 `rg`,并由逐文件 100% 覆盖率门禁兜底。
|
||||
- transcript 可见落盘通知仍有快照缺口:此功能合入时记录了缺口说明,没有快照。快照层会回放 acp-agent 树;在其中加入搜索插件会改变组装的系统提示词,必须使用真实密钥重新录制每一份预期输出,而实现环境没有密钥。落盘通知的确切 transcript 文本由单元测试固定(`formatGlobOutput`/`formatGrepOutput` 以及通过注册表执行的落盘测试);下一次拥有密钥的会话应把插件接入 acp-agent 树,并运行一次 `test:snapshot:record`。
|
||||
|
||||
## 后果
|
||||
|
||||
- `glob` 和 `grep` 是 `@deepseek-ai/dsh-tool-fs-search` 中的条件式面向模型工具,不是 `ctx.fs` 提供方方法,也不属于现有 `@deepseek-ai/dsh-tool-fs` 根插件。只有 bash 执行器能找到 `rg` 时才会注册;该包注入 `tools`、`systemPrompt` 和 `bash`,不注入 `fs`,并使 `ctx.spillStore` 保持可选,读取时使用 `ctx.get('spillStore')`。
|
||||
- Schema 严格为 `glob(pattern, path?)` 和 `grep(pattern, path?, include?)`;搜索上限与超时是带默认值并经过验证的 Config 字段(`globMaxResults`、`grepMaxMatches`、`grepMaxLineBytes`、`rawOutputMaxBytes`、`timeoutMs`)。
|
||||
- 工具通过 `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` 执行,转发 `exec.signal`,绝不调用 `ctx.bash.start()`,也绝不公开 bash task id。如果存在 `exec.agent?.session.header.cwd`,bash 请求 workdir 来自该值;解析后的 `spec.workdir` 决定执行与相对路径显示。
|
||||
- 工具向 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,只解析上限内未截断的 stdout,并将超限或仍被截断的原始输出视为明确的搜索失败;绝不向模型公开原始 `rg` 输出。
|
||||
- 只要可用,过大的完整格式化结果会通过 `ctx.spillStore.saveText()` 保存,而内联结果保持有界;落盘失败、后端缺失或所有者缺失时,系统保留内联结果并报告未保存的剩余内容,绝不会返回 `isError`。
|
||||
- 包 README、生成的配置目录与导出 JSDoc 会记录 Config 字段和 `SEARCH_*` 代码;tui-agent 示例会提供条件式工具插件(acp-agent 树等待完成上述快照重新录制);fs 组 README 会记录 `rg` 可用性以及 bash/文件系统共置部署要求。
|
||||
|
||||
## 风险
|
||||
|
||||
在宽泛模式下,完整运行的 `grep` 可能比提前停止搜索更慢。v1 为了简化实现并恢复完整结果而接受这项成本,同时通过工具超时、bash 超时、`rawOutputMaxBytes` 和输出上限加以约束。如果实际运行过慢,仍可采用直接 ripgrep 或前台流式替代方案。
|
||||
|
||||
Shell 命令构造是最尖锐的安全边界。`ctx.bash` 接受命令字符串而不是 argv 向量,因此实现必须集中处理 shell 引用,并测试恶意模式、带空格路径、以短横线开头的模式、引号、换行和 glob 元字符。
|
||||
|
||||
v1 假设 bash 与文件系统共置部署。如果 bash 搜索一个工作区,而 `read` 工具基于另一个根解析路径,返回路径可能无法继续读取。该包会记录这项要求,但不在运行时验证。
|
||||
|
||||
落盘定位符由后端负责。当前本地后端返回本地文件系统路径,适用于 `read`/`grep` 能打开这些文件的部署;远程或工作区受限部署可以使用另一种后端,让其定位符和检索提示指向受支持的检索机制。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-10-agent-session-identity-and-log-location.md: a55bf276bff998a94f84ec1af078022e4881903c
|
||||
2026-07-10-agent-session-identity-and-log-location.zh.md: 84b8b22187ccd1078ff13e39f4a345efbecfaeac
|
||||
+2
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-10-agent-session-identity-and-log-location.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An agent can identify its workspace through `session.header.cwd`, but a model using bash cannot reliably identify the session that owns the call or the durable transcript that records it. Searching `./.sessions` guesses deployment config and JSONL layout; custom roots, alternate persistence backends, resume, forks, and concurrent parent/child agents make that guess unreliable. Hooks have the same need for transcript location, while future plugins may need to expose other harness-owned environment facts to shell commands.
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# Agent Note: 向工具与钩子公开 agent(智能体)会话标识和 JSONL 位置
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-10-agent-session-identity-and-log-location.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
agent 可以通过 `session.header.cwd` 识别其工作区,但使用 bash 的模型无法可靠识别当前调用所属的会话,也无法找到记录该调用的持久 transcript(文本记录)。搜索 `./.sessions` 等同于猜测部署配置和 JSONL 布局;自定义根目录、替代持久化后端、恢复、fork,以及并发运行的父子 agent,都会让这种猜测失效。钩子同样需要 transcript 位置,而未来的插件也可能需要向 shell 命令公开其他由 harness 所有的环境事实。
|
||||
|
||||
这项边界必须维持两个属性:事实的所有者决定如何解析该事实;每个子进程接收每次执行的快照,而不是进程级可变全局状态。尤其是嵌套 harness 不能把环境中的 `DSH_*` 值泄漏给当前 agent、持久化后端或配置均可能不同的子进程。
|
||||
|
||||
## 决策
|
||||
|
||||
在 [`SessionPersistence`](../architecture/2026-06-14-session-persistence.md) seam 上增加同步、无副作用的位置查询:
|
||||
|
||||
```ts
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
|
||||
interface SessionLocation {
|
||||
readonly kind: string
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
interface SessionPersistence {
|
||||
locate(meta: SessionHeader): SessionLocation | undefined
|
||||
}
|
||||
```
|
||||
|
||||
`path` 是指向该后端为 `meta` 保留的专用日志的绝对本地路径;`kind` 标识其表示形式。JSONL 使用解析后的根目录和路径辅助函数返回 `{ kind: 'jsonl', path }`。SQLite 以及任何无法诚实提供逐会话本地产物的后端均返回 `undefined`。该查询不会创建或刷写任何内容,因此即使文件尚不存在,也可以报告延迟创建的目标路径。
|
||||
|
||||
面向模型的 bash 包(package)拥有一个 `ctx.bashEnv` 注册表。贡献方声明稳定名称、它可能返回的每个 `DSH_*` 键、每个键的说明,以及 `resolve(execution: ToolExecution)`。贡献方名称重复、键所有权重复、使用保留键、声明格式错误、运行时输出未声明或输出不是字符串时,系统都会明确失败。注册属于 Cordis effect,并随贡献插件的 fiber 一同移除。`list()` 无需运行解析器即可公开声明,从而让环境接口可供诊断工具和未来的提示词/UI 消费方枚举。
|
||||
|
||||
注册表会为每次前台和后台 bash `ToolExecution` 重新构建受信任的覆盖层:
|
||||
|
||||
- `DSH_HOME` 始终是配置的 Harness home 绝对路径。独立的 [`@deepseek-ai/dsh-paths`](../../../../packages/util/paths/README.md) 工具库规定其优先级:显式 `dshHome`,其次是环境中的 `$DSH_HOME`,最后是 `~/.dsh`。
|
||||
- `DSH_SHELL=1` 始终存在,用于标识由 DeepSeek Harness 管理、面向模型的 bash 子进程。
|
||||
- 执行具有关联 agent 时,`DSH_SESSION_ID` 存在并等于 `agent.session.header.id`。
|
||||
- 内置的持久化转换层提供 `DSH_SESSION_JSONL` 的条件是 `ctx.sessionPersistence.locate(header)` 返回 `kind: 'jsonl'`。
|
||||
|
||||
会话持久化仍然是事实所有者:JSONL 不依赖 tool-bash,也不会自行注册 shell 变量;钩子继续直接使用 `locate()`。tool-bash 是把持久化事实转换为 shell 约定的转换层。其他需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。
|
||||
|
||||
bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量进行过滤和通道校验。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层。普通 `env` 仍是钩子所用的通用进程内插件接口,但不能包含受管理的键;对称地,`dshEnv` 不能包含普通键。本地执行器会在 spawn 前拒绝任一错误通道,移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。
|
||||
|
||||
bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管理的 `$DSH_*` 变量提供,可以在需要时查看。它不会枚举持久化专用键,也不会添加永久的系统提示词章节。工具 schema 已记录在请求 header 中,工具输出则记录为 `tool/result`,因此无需新增会话事件。
|
||||
|
||||
[Claude Code 和 Codex 钩子桥接层](2026-06-30-hook-bridges.md)在构造 payload 时,从同一持久化 seam 解析 transcript 位置。Codex 使用 `transcript_path: string | null`;Claude Code 保留其字符串字段,并回退为 `''`。钩子查询不会物化或刷写会话。
|
||||
|
||||
## 同类产品调研
|
||||
|
||||
同类产品把稳定标识与物理存储分开处理。Codex 向 spawn 的 shell 注入稳定的 `CODEX_THREAD_ID`,而 recorder 和钩子接口负责提供 transcript 路径。Claude Code 通过结构化的钩子/状态输入提供 `session_id` 和 `transcript_path`。OpenCode 在结构化工具上下文中携带标识;Kimi Code 展开会话占位符;Reasonix 则把活动会话路径保存在控制器上。可移植的规则是:在调用边界注入标识,由存储层解析位置,绝不在并发 harness 中使用进程级的当前会话全局变量。
|
||||
|
||||
## 生命周期与持久化语义
|
||||
|
||||
新会话在第一个轮次之前获得 id,因此它的首次 bash 调用即可读取 `DSH_SESSION_ID` 和 JSONL 目标。JSONL 文件可能要等到第一次成功的轮次结束检查点后才存在,而且在一个轮次仍未结束时,它只包含上次刷写的前缀。`DSH_SESSION_JSONL` 是位置提示,不是授权凭据或新鲜度保证。
|
||||
|
||||
恢复操作复用已加载的 header,因此 id 和位置不变。fork 和 spawn 会创建新的会话 id 与位置。父子调用分别从自己的 `ToolExecution.agent` 解析事实;即使调用重叠,每条命令也会收到不可变快照。替换持久化服务会影响后续收集,因为转换层在执行时查询 `ctx.get('sessionPersistence')`;注册表本身受 effect 作用域约束,并且可安全用于 HMR(热模块替换)。
|
||||
|
||||
`dshHome` 是与会话无关的部署上下文。agent-core 通过 `@deepseek-ai/dsh-paths` 解析出一个值,并将其同时传给 tool-bash 和本地 skill(技能)发现;独立消费方调用同一解析器。如果顶层 `dshHome` 与 `skills.local.dshHome` 均已提供但解析结果不同,组合会失败,而不会公开互相矛盾的 home。持久化可以独立变更,无需把其事实冻结到会话前缀中。
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试覆盖注册表声明校验、effect 释放、逐次执行收集、`dshHome` 优先级,以及本地执行器清理并重建 `DSH_*` 的顺序。请求录制测试覆盖前台/后台快照、无 agent 调用、持久化不存在或为 JSONL、忽略模型 `env`,以及父子隔离。JSONL/SQLite 定位器契约测试与两套钩子桥接测试均锁定 transcript 可用和不可用两种方言。
|
||||
|
||||
一项无密钥的完整循环集成测试会在第一个轮次驱动真实的 agent loop、JSONL 持久化、tool-bash 与 bash-local。子进程打印 `DSH_HOME`、`DSH_SHELL`、会话 id、JSONL 目标和继承的陈旧哨兵值;测试校验当前值、陈旧变量不存在、刷写前文件不存在,并最终检查持久化 header。快照覆盖会锁定录制请求 header 中的通用 bash 说明。该契约属于确定性的本地执行,不涉及模型选择,因此无需带密钥测试。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只提供 id,再用 `find`。** 搜索无法得知自定义根目录或后端布局,并且在多会话环境下存在竞态。
|
||||
|
||||
**只提供绝对路径。** 路径可能不可用、延迟创建或取决于表示形式,不能作为稳定的会话标识。
|
||||
|
||||
**使用全局 `process.env`。** 并发 agent 会互相覆盖,嵌套 harness 也会继承陈旧的当前会话值。
|
||||
|
||||
**把持久化说明放入会话前缀。** 活动服务可以在 HMR 或未来的后端切换中改变,而会话前缀保持冻结;持久化专用指引会因此变得陈旧。
|
||||
|
||||
**使用类型化 waterfall 事件。** 监听器不运行就无法声明所有权,而后续监听器可以无提示地覆盖键。注册表能在注册时检测键冲突,并且保持可枚举。
|
||||
|
||||
**让每个持久化后端直接注册 bash 环境。** 这会反转依赖方向,让存储层依赖某一个消费方,并迫使未使用 bash 的部署也引入它。钩子仍然需要 `locate()`。
|
||||
|
||||
**增加面向模型的 `session_info` 工具。** bash 已经提供查询接口,新增工具只会多出 schema 和一次调用;注册表可以扩展至未来的环境事实,无需为每项事实增加一个工具。
|
||||
|
||||
## 影响
|
||||
|
||||
每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中的完整 `DSH_*` 命名空间由 harness 管理:系统移除环境中已有的受管理值、重新加入当前受信任的值,并禁止普通调用方通过 `env` 绕过所有权检查。
|
||||
|
||||
该命名空间可被发现,但并非秘密。路径可能泄露配置的根目录,延迟创建的目标也可能不存在或处于陈旧状态,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,在归属关系重要时校验 transcript 元数据,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-10-parallel-tool-call-execution.md: c67ae61939a3e7974f9bf729058a57f5576308a1
|
||||
2026-07-10-parallel-tool-call-execution.zh.md: a80317aa951cbf3a9cae0651348c99712a4193d5
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-10-parallel-tool-call-execution.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An assistant message may contain several sibling `tool-call` blocks. Running them serially adds the latency of independent reads and web requests even though the model has already requested them together.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Agent Note: 按单次调用安全性并行执行工具调用
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-10-parallel-tool-call-execution.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
一条 assistant 消息可以包含多个并列的 `tool-call` 块。尽管模型已经同时请求了这些调用,串行执行仍会叠加各个独立读取和 Web 请求的延迟。
|
||||
|
||||
并发属于宿主调度范畴,不是面向模型的工具元数据。循环需要在不硬编码工具名称、不向 JSON Schema 暴露调度策略的前提下,判断哪些调用可以重叠执行。
|
||||
|
||||
会话日志仍是权威记录:每个已启动的调用都有审计事件,都会获得结果;无论完成顺序如何,模型历史都按原始调用顺序观察结果。
|
||||
|
||||
## 决策
|
||||
|
||||
每个工具都可以提供可选的 `isConcurrencySafe(args)` 分类器。该分类器必须是同步纯函数:它只检查当前调用已解析的参数,不执行 I/O 或任何变更。只有显式返回 `true` 才表示选择并行;分类器缺失、参数无效、分类器抛错或返回任何其他值,都会使该调用按独占方式执行。规范类型契约见[工具数据结构](../../../../docs/core-data-structures/tools.md)。
|
||||
|
||||
分类器有意设计为一元函数。返回 `true` 表示工具承诺:此调用可以与任何同样返回 `true` 的并列调用重叠执行。调度器不会比较调用,也不会证明它们的资源访问相容。
|
||||
|
||||
这个一元分类器仍然可以感知输入。工具可以将只读操作分类为并行,将变更操作分类为独占。该接口无法表达「仅当路径不同时,这些写入才安全」之类的关系规则,因此,安全性依赖并列调用的调用仍按独占方式执行。
|
||||
|
||||
`defineTool()` 先验证参数,再调用类型化分类器。无效参数会被归为独占,且只有该调用真正执行时才会产生常规参数错误。`ctx.tools.executionMode(exec)` 会解析当前有效的工具定义,并返回带标签的 `parallel` 或 `exclusive` 模式;未知工具将以安全方式退化为独占。
|
||||
|
||||
使用带标签的模式,而不是公开布尔型调度器 API,使得以后可以表达感知资源的变体,无需改变分类器契约。
|
||||
|
||||
## 调度与顺序
|
||||
|
||||
循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 `ToolExecution`,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。如果注册表变更使该调用变为独占,当前池会先完全排空,然后该调用才作为下一个屏障启动。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
[parallel read(A), parallel read(B), exclusive write(A), parallel read(C)]
|
||||
|
||||
→ [read(A), read(B)]
|
||||
→ [write(A)]
|
||||
→ [read(C)]
|
||||
```
|
||||
|
||||
`read(A)` 和 `read(B)` 可以重叠执行。`write(A)` 要等两者都完成后才启动,`read(C)` 则要等写入完成后才启动。
|
||||
|
||||
每组都使用一个由 `maxParallelToolCalls` 限制上限的滚动池:循环先按模型顺序启动调用,直到达到上限;每有一个调用结算,就再启动一个。独占组是容量为 1 的池。将上限设为 `1` 可保持串行执行。
|
||||
|
||||
只有派发和工具主体会重叠执行。`tools/pre-execute` 和 `tools/post-execute` 按模型顺序运行,因为中间件可能维护对顺序敏感的状态。`tools/execute` 包装层会环绕并发派发运行,因此必须能在不同执行之间重入。
|
||||
|
||||
每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。
|
||||
|
||||
如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。
|
||||
|
||||
Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。
|
||||
|
||||
## 安全契约
|
||||
|
||||
工具返回 `true` 即承诺:其主体可以与其他并行调用同时运行。它不得直接变更父会话或其他由父级拥有的状态;它将输出返回给循环,由循环按模型顺序提交。
|
||||
|
||||
执行期间触及的任何共享状态都必须支持并发。这也包括工具包装层和提供方:它们可以在内部串行化,也可以实施自身容量限制,但必须在并发派发时不破坏状态。
|
||||
|
||||
## 配置与声明
|
||||
|
||||
`maxParallelToolCalls` 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 `10`;`1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.md)。
|
||||
|
||||
当前实现中的声明保持保守。Web 搜索、Web 获取和文件系统读取选择并行。文件系统写入与编辑、bash 工具、subagent 委派、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。subagent 可能共享父级的工作区或外部资源,而一元分类器无法证明并列委派的作用互不重叠。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。
|
||||
|
||||
文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`。
|
||||
|
||||
## 验证
|
||||
|
||||
单元测试覆盖固定了安全退化的分类、类型化参数验证、分组、屏障、替换注册表后的实时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文,以及中止排空。第一方测试固定每项并行声明。
|
||||
|
||||
快照覆盖固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 覆盖固定其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**保持串行执行。** 这可以避免新的顺序和中止情形,但会保留独立并列调用所产生的不必要延迟。
|
||||
|
||||
**使用一个工具级布尔值。** 固定的 `supportsParallelToolCalls` 标志更小,但无法区分同一工具的只读操作和变更操作。感知参数的分类器保留了这项区分。
|
||||
|
||||
**使用有状态的分类。** 向分类器提供实时 agent、注册表或 I/O 访问,会使决策依赖分类器的运行时机,并在分类与派发之间留下缺口。可变授权和陈旧状态检查仍属于执行时职责。
|
||||
|
||||
**使用感知并列调用或感知资源的分类。** 调度器可以成对比较调用,或让每个调用声明资源读写要求。这样可以并行化不冲突的写入,却要求不相关工具共享资源标识和冲突语义。一元契约选择放弃这部分并发性,并在安全性取决于关系时安全退化。
|
||||
|
||||
**并行执行完整的工具流水线。** 这样可以让循环继续使用公开的单调用 API,但会并发运行 pre-execute 和 post-execute 中间件。现有防护和钩子桥可能承载有序状态,因此只允许派发重叠。
|
||||
|
||||
**公开分阶段方法或调度 waterfall。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在出现另一个消费方之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略 seam 保留了插入点。
|
||||
|
||||
**在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。
|
||||
|
||||
**使用固定大小的窗口。** 如果在启动下一个窗口前等待当前窗口的每个调用,一个缓慢调用就会使容量闲置。滚动池在保持上限的同时避免了这项延迟。
|
||||
|
||||
**向模型暴露并发元数据。** 模型已可以发出并列调用。宿主调度元数据会扩大请求,却无助于工具选择。
|
||||
|
||||
## 影响
|
||||
|
||||
该设计以安全退化为原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。
|
||||
|
||||
在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。
|
||||
|
||||
有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序,同时实时界面仍可显示待处理进度。
|
||||
|
||||
并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。
|
||||
|
||||
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-10-sqlite-session-query-provider.md: be8795b609b52eeb03268c4986b52004eef0dba9
|
||||
2026-07-10-sqlite-session-query-provider.md: 98618a7eb572ce59c5fa5984675c9dc57b3f4289
|
||||
2026-07-10-sqlite-session-query-provider.zh.md: bb3650da907cf86a853f748fa0ee40d5c2168709
|
||||
|
||||
@@ -34,13 +34,13 @@ Both persistent and live FTS5 tables use `unicode61`. The implementation experim
|
||||
|
||||
The shared extractor includes message text, reasoning, nested tool-call/result content, tool names and arguments, blocked-prompt reasons, todo status/content, and error or terminal status detail. Structural boundaries, stream chunks, request headers, successful completion markers, and unknown declaration-merged event/content variants produce no document. Surface classification reuses `foldSurface()` so search agrees with model-history derivation.
|
||||
|
||||
One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. It never calls the backend's mutating `load()` for an id currently owned by `ctx.sessions`; the TEMP overlay records persisted availability, and the durable base refreshes after the live owner detaches. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries.
|
||||
One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. It passes the caller's exact abort signal into snapshot listing and non-mutating inspection, directly awaits every started backend operation, and checks cancellation after each await and before starting more work. Cancellation therefore rejects only after active backend work is quiescent, starts no subsequent observation or reconciliation step, and keeps a following search serialized behind cleanup even if a backend ignores the signal. The operation never calls the backend's mutating `load()` for an id currently owned by `ctx.sessions`; the TEMP overlay records persisted availability, and the durable base refreshes after the live owner detaches. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries.
|
||||
|
||||
Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources.
|
||||
|
||||
The derived schema has its own application id and monotonic schema version. Persistent and TEMP session metadata store the integer `SessionHeader.createdAt` contract in strict `INTEGER` columns. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. On POSIX filesystems, missing directories and database files are created owner-only so new SQLite sidecars inherit that mode; existing modes are preserved. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned.
|
||||
|
||||
Cancellation rejects queued operations and caller waits around asynchronous source observation without committing an aborted observation. Node's synchronous `DatabaseSync` MATCH call cannot be interrupted once it is executing on the JavaScript thread, so the service checks the signal at serialized boundaries but does not promise mid-statement preemption.
|
||||
Cancellation rejects queued operations promptly. Once asynchronous source observation starts, the caller waits for that backend promise to settle before rejection, without committing an aborted observation or starting more source/index work. Node's synchronous `DatabaseSync` metadata and MATCH calls cannot be interrupted once executing on the JavaScript thread, so the service checks the signal around those calls but does not promise mid-statement preemption.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -54,6 +54,6 @@ Cancellation rejects queued operations and caller waits around asynchronous sour
|
||||
|
||||
Search has a small provider-neutral API while its only backend owns every derived-index state transition. The separate database adds configuration and a lightweight snapshot read before queries, but index corruption, reset, and tokenizer changes cannot endanger canonical logs. Durable revisions avoid full-log reads and rewrites for unchanged sessions; TEMP live overlays preserve current-session truth without making uncheckpointed events durable.
|
||||
|
||||
The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is effective while queued or awaiting sources, but synchronous SQLite execution remains a non-preemptible section.
|
||||
The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is prompt while queued and quiescent while awaiting sources; synchronous SQLite execution remains a non-preemptible section bracketed by signal checks.
|
||||
|
||||
Unit coverage pins extraction, filters, both search scopes, all default surfaces, metadata-before-ranking, snippets, literal escaping, deterministic ties, complete pagination, scoped cursor invalidation, dynamic persistence mount/unmount, restart reconciliation, live shadow/reveal/reopen, schema safety, rollback retry, and queued/in-flight source-wait cancellation. A keyless real-Loader-path test combines the package with the real SQLite persistence backend.
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-auto-pane-title.md: 069fd33a8874d9ad3d4472dd13f5130b2df65f08
|
||||
2026-07-21-tui-auto-pane-title.zh.md: 580f36b2563e21231a22cab3f0c1689c6f3e8d9d
|
||||
2026-07-13-session-query-tracing.md: 47c12824a331546676d3bc79920f861afe648431
|
||||
2026-07-13-session-query-tracing.zh.md: 485060f9e57b5644f7b364e2120bfe30607b1945
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-13-session-query-tracing.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Session relationships are encoded across immutable headers, positional surface operations, and logged provenance arrays. A consumer reconstructing those relationships directly would need to duplicate corpus precedence, surface folding, malformed-log handling, deterministic lineage ordering, and cloning. Positional replacement and provenance are different graphs, so collapsing them into one generic edge type would also lose meaning.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: 会话查询关系追踪
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-13-session-query-tracing.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
会话关系分散编码在不可变 header、位置式表面操作和已记录的来源数组中。消费方如果直接重建这些关系,就必须重复实现语料优先级、表面折叠、格式错误日志的处理、确定性的谱系顺序和克隆。位置替换与来源属于不同的图,因此把两者合并为一种通用边类型也会丢失含义。
|
||||
|
||||
## 决策
|
||||
|
||||
`ctx.sessionQuery` 除精确读取外,还公开 `traceSession(sessionId)` 和 `traceEvent({ sessionId, seq })`。两者都是基于现有「实时数据优先」语料的一次性视图:会话追踪读取一次完整语料列表,事件追踪读取一份逻辑日志并执行一次规范表面折叠。服务在调用结束后不会保留谱系、反向索引或替换状态。
|
||||
|
||||
`SessionLineageTrace` 返回目标、按从直接父级到外层父级排序的已知父级,以及递归的后代树;同级节点先按创建时间排序,再按 session id 排序。`complete: true` 会携带已知根节点;`complete: false` 会携带第一个无法解析的父级 id。与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。
|
||||
|
||||
`SessionEventTrace` 将位置关系与来源关系分开保留。`replacedBy` 是直接的位置替换者,`replacementChain` 沿替换者追踪至最终节点,`replacedEventSeqs` 则列出目标直接移除的真实表面节点。`sourceEventSeqs` 保留日志中直接来源的顺序,而 `derivedEventSeqs` 按日志顺序列出后续的直接反向引用。来源关系不会传递展开。
|
||||
|
||||
## 校验边界
|
||||
|
||||
事件追踪会在分析表面之前检查目标是否存在。随后,事件列表与追踪都会使用 `dsh-session` 的单遍表面折叠,对加载的日志整体进行接受或拒绝:事件 seq 从零开始且连续;表面标记符合事件类型的适用范围;只有表面事件类型可以携带来源;存在的数组必须非空且没有重复项;每个来源必须是更早的 seq;每次位置替换必须指明并引用它所移除的全部表面节点。任何契约违例都使用 `SESSION_QUERY_INVALID_SURFACE`;系统不存在只用于分类、要求更弱的表面标准。
|
||||
|
||||
所有返回的记录与数组都与内部状态分离。已知的实时事件追踪绝不查询持久化;持久化事件追踪保留精确读取所要求的列表/加载一致性检查。会话谱系必然属于跨语料操作,因此也保留跨语料的持久化失败语义。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **公开独立的追踪辅助函数**:不予采纳,因为源优先级与状态分离边界属于 `ctx.sessionQuery`;公开辅助函数会诱使调用方绕过该边界。
|
||||
- **合并替换边与来源边**:不予采纳,因为位置替换可以遮蔽表面节点,同时引用不在表面上的构造输入,而消费方需要区分这两种含义。
|
||||
- **返回传递来源闭包**:不予采纳,因为这会掩盖日志中直接记录的证据、增大结果,并让一条遥远的格式错误边改变原本局部的输出。
|
||||
- **在格式错误的来源关系上返回尽力而为的追踪结果**:不予采纳,因为结构上看似合理的局部图会显得具有权威性。当规范的关系契约损坏时,精确检查必须快速失败。
|
||||
|
||||
## 影响
|
||||
|
||||
消费方无需缓存或引入第二份语料,即可获得确定性的关系视图。事件追踪每次调用都会执行全日志校验和分配,而谱系追踪每次调用都会列出完整的逻辑语料。这些成本让真源保持明确,并且与承载内容的全文搜索及过滤 API 相互独立。
|
||||
|
||||
该功能具备单元测试和服务层覆盖率,但没有快照或端到端 fixture(测试前置数据),因为它没有引入面向模型的消费方、transcript(文本记录)变更或跨进程协议。
|
||||
@@ -1,59 +0,0 @@
|
||||
# Agent Note: Optional time-context plugin
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-14-time-context-plugin.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The dynamic system-prompt storage and refresh decision in this record is superseded by [Durable per-step time context](2026-07-16-durable-per-step-time-context.md). The opt-in package, zoned formatting, and validation remain; the follow-up owns the current model-visible and durability contract.
|
||||
|
||||
An agent request has no live clock unless a deployment puts one in prompt text or gives the model a query tool. Static text becomes stale, while a tool call adds overhead to ordinary reasoning about dates, deadlines, or idle time. Without elapsed time, the model cannot distinguish an immediate follow-up from one sent hours after the preceding message.
|
||||
|
||||
Prompt assembly can derive both facts per step from durable session timestamps, and request-header logging can record the exact rendered value. Accumulating stale readings in conversation history or waking idle agents would violate the existing request lifecycle.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-time-context` is an opt-in function plugin at `packages/context/time-context/`. The `context/` product group holds bounded request-context enrichments that define neither a tool nor a service. `dsh-agent-spine-demo` and shipped examples do not load the package; deployments mount it explicitly when its token and disclosure costs are acceptable.
|
||||
|
||||
The plugin registers the global `context:time` system-prompt section at order 10, after the deployment persona and before tool guidance. For an active turn it emits an ISO-shaped timestamp with numeric UTC offset and IANA zone, plus a compact whole-second duration since the last model-visible message before the turn opened. Bare and idle assemblies receive an empty section.
|
||||
|
||||
### Previous-message baseline
|
||||
|
||||
At a turn's first assembly, the provider scans before `turn/start` for the latest `user/message`, `assistant/message`, `tool/result`, `context/message`, or `steering/message`. It excludes the current prompt so the duration expresses the inter-turn gap instead of approximately zero. Every refresh in that turn keeps the same baseline, and the first turn reports `unavailable (no earlier message in this session)`.
|
||||
|
||||
The baseline is the session event's append time, not an unlogged client timestamp. Resume and fork behavior are therefore deterministic from the durable log, and the model-visible value remains reconstructable without a new event. A backward wall-clock adjustment clamps the duration to zero.
|
||||
|
||||
### Refresh policy
|
||||
|
||||
`refreshIntervalMs` defaults to 60,000 and must be a non-negative safe integer. Every turn's first request refreshes. Later assemblies in that turn reuse the block until its age reaches the interval; `0` refreshes every step. No timer creates work during model calls, tools, or idle time because refresh is request-bound.
|
||||
|
||||
When `timeZone` is omitted, `Intl.DateTimeFormat` resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit value must be an IANA identifier and is validated at load. The captured zone remains stable until plugin reload, and the ISO-shaped local timestamp includes its current numeric offset so daylight-saving changes stay explicit. This is the deployment process's zone, not a remote user's zone.
|
||||
|
||||
### Logging and token shape
|
||||
|
||||
The loop records the temporal block in full `request/header` snapshots before transmission, satisfying the [reconstructable-requests contract](../architecture/2026-07-05-reconstructable-requests.md). Each request carries one current block; earlier readings do not remain in conversation history. The plugin owns the fact and contributes it through the prompt registry, following the [prompt-variables Agent Note](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) without a loop special case.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests pin formatting, baselines, refresh policy, validation, per-agent state, disposal, and load-time system-zone capture. A real agent-loop test pins the transmitted prompt and full `request/header` snapshots. A keyless subprocess e2e boots a test-only `cordis.yml` through the real Loader and stdio app, omits `timeZone` under a controlled `TZ`, drives two turns, and verifies the persisted request headers externally. Default snapshot compositions omit the plugin, so their transcript fixtures contain no temporal block.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Append a `context/message` on every turn or refresh** — rejected because readings and token cost would accumulate in history. Replacing a prior surface node would preserve its old position, while replacing the tail would hide intervening conversation.
|
||||
- **Use `agent/session-prefix`** — rejected because the session-stable prefix cannot represent a per-turn or per-step clock.
|
||||
- **Mutate requests in `agent/request`** — rejected because that seam shapes call config after the message boundary; inserted model content would bypass prompt-pressure accounting and request-header logging.
|
||||
- **Register separate `{{current_time}}` and `{{elapsed}}` variables** — rejected because independent providers can sample different instants and require shared caching. One section records the pair atomically without a deployment-authored template.
|
||||
- **Refresh from a background timer** — rejected because a new value has no consumer outside request assembly. Timer-driven `agent.inject()` would create turns and wake idle sessions merely to report time passing.
|
||||
- **Keep UTC as the omitted default** — rejected because an explicitly enabled clock should follow its deployment environment unless the operator chooses UTC. `timeZone: UTC` remains available when a deployment requires it.
|
||||
- **Add a time-zone detection library** — rejected because Node's `Intl` runtime already exposes the process's IANA zone. Another dependency cannot infer a remote user's zone either.
|
||||
- **Mount the plugin in `dsh-agent-spine-demo`** — rejected because time zone, disclosure, token budget, and freshness are deployment policy. Opt-in keeps default context stable.
|
||||
- **Place the package in `core/`** — rejected because `core/` owns the product API spine, while this plugin is an optional leaf with no service key.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Opted-in models receive a zoned clock and inter-turn duration without a tool call. The system-prompt cost is fixed per request instead of growing with the session.
|
||||
- An omitted `timeZone` follows the process's `TZ`, host, or container zone as observed at plugin load. Operators must configure an explicit zone when the deployment environment does not represent the intended user.
|
||||
- A refresh changes the request header and can add a full `request/header` snapshot with reason `change`. `refreshIntervalMs` trades freshness against the number and size of durable full snapshots; `0` records a new value on every step whose whole-second rendering changes.
|
||||
- No request exists solely to refresh time. A long-running tool leaves the prior reading until the next step assembles.
|
||||
- Duration reflects harness processing time at durable append boundaries, not client-network latency before logging. Preserving a client-origin timestamp requires a separate durable input contract.
|
||||
@@ -1,59 +0,0 @@
|
||||
# Agent Note:可选时间上下文插件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-14-time-context-plugin.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
本记录中的动态系统提示词存储和刷新决策已由[持久的逐步骤时间上下文](2026-07-16-durable-per-step-time-context.md)取代。需要显式启用的包(package)、分区时间格式和校验仍然保留;后续 Agent Note 负责当前的模型可见与持久性契约。
|
||||
|
||||
如果部署方既未在提示词中提供时钟,也未给模型提供查询工具,agent(智能体)请求就无法获得实时准确的时间。静态文本会变得陈旧,而对于日期、截止时间或闲置时长等常规推理,调用工具会增加开销。缺少已经过去的时长时,模型无法区分紧接着发送的消息与上一条消息几小时后才发送的消息。
|
||||
|
||||
提示词组装流程可以在每个步骤中根据持久会话时间戳派生这两项信息,请求头日志则可以记录实际渲染的确切值。在会话历史中累积陈旧读数或唤醒空闲 agent 都会违反现有请求生命周期。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-spine-demo` 和仓库提供的示例都不会加载该包;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。
|
||||
|
||||
该插件注册顺序值为 10 的全局系统提示词区段 `context:time`,位置在部署方角色设定之后、工具指导之前。对于活跃轮次,它会输出带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳,以及从轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,该区段为空。
|
||||
|
||||
### 上一条消息基线
|
||||
|
||||
在轮次首次组装时,提供方会在 `turn/start` 之前查找最近的 `user/message`、`assistant/message`、`tool/result`、`context/message` 或 `steering/message`。它会排除当前提示词,使时长表达轮次间隔,而不是接近零。同一轮次中的每次刷新都保留这条基线;首个轮次报告 `unavailable (no earlier message in this session)`。
|
||||
|
||||
基线采用会话事件的追加时间,而不是日志中不存在的客户端时间戳。因此,恢复和 fork 行为可以从持久日志中确定性重现,模型可见值也无需新增事件即可重建。系统挂钟向后调整时,插件会将时长钳制为零。
|
||||
|
||||
### 刷新策略
|
||||
|
||||
`refreshIntervalMs` 默认值为 60,000,并且必须是非负安全整数。每个轮次的首次请求都会刷新。同一轮次中的后续组装会复用该区块,直至其存在时间达到该间隔;设为 `0` 时每个步骤都刷新。刷新仅由请求驱动,因此在模型调用、工具运行或空闲期间,计时器不会创建任务。
|
||||
|
||||
省略 `timeZone` 时,`Intl.DateTimeFormat` 会在插件加载时解析一次 Node 进程的系统时区。Node 会遵循 `TZ`;没有该覆盖值时,时区由主机或容器提供。显式值必须是 IANA 标识符,并在加载时接受校验。捕获的时区在插件重新加载前保持稳定,形似 ISO 的本地时间戳包含其当前数字偏移,使夏令时变化保持显式可见。该默认值代表部署进程的时区,而不是远程用户的时区。
|
||||
|
||||
### 日志与 token 形态
|
||||
|
||||
agent loop(智能体循环)会在发送前通过完整的 `request/header` 快照记录时间区块,从而满足[可重建请求契约](../architecture/2026-07-05-reconstructable-requests.md)。每个请求只携带一个当前区块;先前的读数不会保留在会话历史中。该插件拥有时间信息,并按照[提示词变量 Agent Note](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)通过提示词注册表贡献该信息,无需为循环添加特殊分支。
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试固定格式化、基线、刷新策略、校验、逐 agent 状态、资源释放行为,以及系统时区在加载时的捕获行为。使用真实 agent loop 的测试固定实际发送的提示词和完整的 `request/header` 快照。无密钥子进程端到端测试通过真实 Loader 和 stdio 应用启动测试专用 `cordis.yml`,在受控 `TZ` 下省略 `timeZone`,驱动两个轮次,并从外部校验持久请求头。默认快照组合不包含该插件,因此其中的 transcript(文本记录)fixture(测试前置数据)不包含时间区块。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **每个轮次或每次刷新都追加一条 `context/message`**——不予采纳,因为读数和 token 成本会在历史中累积。替换先前的表层节点会保留其旧位置,而替换尾部节点会隐藏中间的会话内容。
|
||||
- **使用 `agent/session-prefix`**——不予采纳,因为会话期间保持稳定的前缀无法表示逐轮次或逐步骤变化的时钟。
|
||||
- **在 `agent/request` 中修改请求**——不予采纳,因为该边界在消息边界之后塑造调用配置;插入模型可见内容会绕过提示词压力核算和请求头日志。
|
||||
- **注册独立的 `{{current_time}}` 和 `{{elapsed}}` 变量**——不予采纳,因为独立提供方可能在不同时间点采样,并且需要共享缓存。单个区段会以原子方式记录两项信息,也不需要部署方编写时间模板。
|
||||
- **通过后台计时器刷新**——不予采纳,因为请求组装之外没有消费新值的对象。由计时器驱动 `agent.inject()` 会创建轮次,并且只为报告时间流逝就唤醒空闲会话。
|
||||
- **省略配置时仍默认使用 UTC**——不予采纳,因为显式启用的时钟应跟随部署环境,除非运维方选择 UTC。需要 UTC 的部署仍可配置 `timeZone: UTC`。
|
||||
- **引入时区探测库**——不予采纳,因为 Node 的 `Intl` 运行时已经能够提供进程的 IANA 时区,而且额外依赖同样无法推断远程用户的时区。
|
||||
- **在 `dsh-agent-spine-demo` 中挂载插件**——不予采纳,因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。
|
||||
- **将包放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。
|
||||
|
||||
## 后果
|
||||
|
||||
- 选择加入的模型无需调用工具,即可获得分区时钟和轮次间隔时长。每个请求的系统提示词成本固定,不会随会话增长。
|
||||
- 省略 `timeZone` 时,插件采用加载时观察到的进程 `TZ`、主机或容器时区。当部署环境不能代表目标用户时,运维方必须显式配置时区。
|
||||
- 刷新会改变请求头,并可能新增一份 reason 为 `change` 的完整 `request/header` 快照。`refreshIntervalMs` 用新鲜度换取完整持久快照的数量与大小;设为 `0` 时,每个整秒渲染结果发生变化的步骤都会记录新值。
|
||||
- 系统不会仅为刷新时间而创建请求。长时间运行的工具会保留先前读数,直至下一步骤开始组装。
|
||||
- 时长反映持久追加边界处的 harness 处理时间,不包含消息进入日志之前的客户端网络延迟。若要保留客户端来源时间戳,需要单独的持久输入契约。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-16-durable-per-step-time-context.md: 2d7076d51dbe1a64e5042230bddc6844141ff265
|
||||
2026-07-16-durable-per-step-time-context.zh.md: 432e0305cf44dcce1053c6580c9f0039309a7af4
|
||||
2026-07-16-durable-per-step-time-context.md: 4bc17b3c08707fcaa4f0f431e71ddbe567a03c9e
|
||||
2026-07-16-durable-per-step-time-context.zh.md: 836c0f83fbe9d6120741a261cf25ce7d8c227bdf
|
||||
|
||||
@@ -12,13 +12,13 @@ A process-local refresh cache makes displayed time depend on state that cannot s
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `context/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing.
|
||||
`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. The `context/` group holds bounded request-context enrichments that define neither a tool nor a service, and shipped examples do not mount this plugin because its time-zone disclosure and token cost are deployment policy. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `user/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing.
|
||||
|
||||
The listener records preparation context before a possible `step/start`. Its prepended registration runs before ordinary automatic compaction listeners, so pressure estimation and any resulting surface rewrite observe a newly appended reading. A later pre-step listener can cancel or fail the attempt before the step opens; the reading remains because the durable log is append-only and this plugin performs no rollback.
|
||||
|
||||
The optional `timeZone` config resolves the Node process's IANA zone once at plugin load when omitted; an explicit value is validated by `Intl.DateTimeFormat`. The timestamp includes the numeric UTC offset and resolved IANA zone.
|
||||
|
||||
The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. A positive value scans the raw session events for the most recent `context/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache.
|
||||
The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. A positive value scans the raw session events for the most recent `user/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache.
|
||||
|
||||
### Text and elapsed baselines
|
||||
|
||||
@@ -29,7 +29,7 @@ Time sampled while preparing turn <turn>, step 1: <timestamp>
|
||||
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
The baseline is the latest preceding user, assistant, tool-result, context, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`.
|
||||
The baseline is the latest preceding user, assistant, tool-result, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`.
|
||||
|
||||
An injected later-step reading is:
|
||||
|
||||
@@ -48,11 +48,7 @@ The plugin contributes nothing to system-prompt assembly. `request/header` conta
|
||||
|
||||
## Testing
|
||||
|
||||
Unit and real-loop tests pin formatting, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, aborted-signal behavior, later-listener cancellation and failure, listener disposal, source and surface metadata, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader and stdio app, drives two turns, and verifies the persisted context events externally.
|
||||
|
||||
## Supersedes
|
||||
|
||||
This decision supersedes the dynamic system-prompt storage and refresh policy in [Optional time-context plugin](2026-07-14-time-context-plugin.md). It keeps the package location, opt-in deployment stance, timestamp formatting, process-zone default, and load-time validation. Durable history replaces the `context:time` prompt section, process-local refresh cache, and request-header deltas; `refreshIntervalMs` controls durable append frequency instead of prompt replacement.
|
||||
Unit and real-loop tests pin formatting, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, aborted-signal behavior, later-listener cancellation and failure, listener disposal, source and surface metadata, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader with the Headless composition, drives two ordered one-shot turns, and verifies the persisted plugin-attributed messages externally.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -61,10 +57,13 @@ This decision supersedes the dynamic system-prompt storage and refresh policy in
|
||||
- **Inject from a background timer** — rejected because idle time has no pending request to consume the value, and timer-driven injection would create durable turns solely to report time passing.
|
||||
- **Expose time only through a tool** — rejected because ordinary temporal reasoning would require an avoidable tool round trip and would not guarantee a reading before every step.
|
||||
- **Use `agent/session-prefix`** — rejected because one loop-instance prefix cannot represent distinct step timestamps and does not accumulate historically attributable readings.
|
||||
- **Mutate assembled requests or register independent prompt variables** — rejected because request-local insertion bypasses the durable surface and separate providers can sample different instants. One attributed context message records the timestamp and elapsed baseline atomically.
|
||||
- **Default to UTC or add a time-zone detection dependency** — rejected because an explicitly mounted plugin follows its process environment unless the operator selects an IANA zone, while no server-side library can infer a remote user's zone.
|
||||
- **Mount the plugin in shipped compositions or place it in `core/`** — rejected because disclosure, time zone, freshness, and history cost are deployment choices for an optional context leaf, not product-spine policy.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Omission or `0` records every eligible preparation attempt; a positive interval reduces append frequency and history growth while preserving durable scheduling across resume.
|
||||
- Timing context remains append-only until compaction shadows older surface nodes, including a preparation reading left by a later cancellation or failure.
|
||||
- The first-step duration normally measures from the prompt that opened the turn, while later-step durations measure model and tool processing since the preceding step context.
|
||||
- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps.
|
||||
- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps. Supporting client-origin time requires a separate durable input contract.
|
||||
|
||||
@@ -12,13 +12,13 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `context/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。
|
||||
`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 分组容纳有界的请求上下文增强,这些增强既不定义工具也不定义服务;已交付示例不挂载此插件,因为时区披露与 token 成本属于部署策略。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `user/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。
|
||||
|
||||
监听器在可能出现的 `step/start` 之前记录准备上下文。它采用前置注册,因此先于普通自动压缩监听器运行,使压力估算和由此产生的表层重写都能观察到新追加的读数。后续预步骤监听器可能在步骤开启前取消尝试或使其失败;持久日志仅追加,且本插件不执行回滚,因此该读数会保留下来。
|
||||
|
||||
省略可选配置 `timeZone` 时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。
|
||||
|
||||
插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `context/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。
|
||||
插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `user/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。
|
||||
|
||||
### 文本与时长基线
|
||||
|
||||
@@ -29,7 +29,7 @@ Time sampled while preparing turn <turn>, step 1: <timestamp>
|
||||
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
|
||||
```
|
||||
|
||||
基线是前一条用户消息、助手消息、工具结果、上下文消息或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。
|
||||
基线是前一条用户消息、助手消息、工具结果或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。
|
||||
|
||||
后续步骤的注入读数为:
|
||||
|
||||
@@ -48,11 +48,7 @@ Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试通过真实 Loader 和 stdio 应用启动,驱动两个轮次,并从外部校验持久化的上下文事件。
|
||||
|
||||
## 取代的决策
|
||||
|
||||
本决策取代[可选时间上下文插件](2026-07-14-time-context-plugin.md)中的动态系统提示词存储和刷新策略。它保留包位置、选择加入式部署、时间戳格式、进程时区默认值和加载时校验。持久历史取代 `context:time` 提示词区段、进程本地刷新缓存和请求头增量;`refreshIntervalMs` 用于控制持久追加频率,而非提示词替换。
|
||||
单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试使用 Headless 组合启动真实 Loader,依次驱动两个单次任务轮次,并从外部校验持久化且来源归属于插件的消息。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -61,10 +57,13 @@ Elapsed since the preceding step context: <duration-or-unavailable>.
|
||||
- **通过后台计时器注入**——不予采纳,因为空闲期间没有待处理请求消费该值,而且计时器驱动的注入会仅为报告时间流逝而创建持久轮次。
|
||||
- **只通过工具提供时间**——不予采纳,因为普通时间推理会产生本可避免的工具往返,也不能保证每个步骤之前都有读数。
|
||||
- **使用 `agent/session-prefix`**——不予采纳,因为一个 loop 实例前缀无法表示不同的步骤时间戳,也不会累计具有历史归属的读数。
|
||||
- **修改已组装的请求或注册独立提示词变量**——不予采纳,因为请求内插入会绕过持久表层,不同提供方也可能在不同时间采样。一条带来源归属的上下文消息会原子地记录时间戳和时长基线。
|
||||
- **默认使用 UTC 或增加时区检测依赖**——不予采纳,因为显式挂载的插件默认遵循其进程环境,除非操作方选择 IANA 时区,而任何服务端库都无法推断远程用户的时区。
|
||||
- **在已交付组合中挂载插件,或把它放进 `core/`**——不予采纳,因为披露内容、时区、新鲜度和历史成本是可选上下文叶节点的部署选择,不是产品主干策略。
|
||||
|
||||
## 后果
|
||||
|
||||
- 省略 `refreshIntervalMs` 或设为 `0` 时,每次符合条件的准备尝试都会留下记录;正数间隔会减少追加频率和历史增长,同时使持久调度在恢复后继续生效。
|
||||
- 时间上下文仅追加并保留到压缩隐藏旧表层节点为止,其中也包括后续取消或失败所留下的准备读数。
|
||||
- 第一个步骤的时长通常从开启轮次的提示词起算,后续步骤的时长则反映自上一条步骤上下文以来的模型与工具处理时间。
|
||||
- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。
|
||||
- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。若要支持客户端来源的时间,需要另行建立持久输入契约。
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Agent Note: Startup slogans replace the configured TUI welcome line
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-20-tui-startup-slogans.zh.md)
|
||||
|
||||
> **Superseded** for the slogan/animation half by the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md): the slogan bank and typewriter reveal shipped, read as weird in use, and were replaced by a subtitle-free banner with a whole-banner sweep. The removal of the configured demo welcome and the animation-lifecycle groundwork (start after `ui.start()`, clear through `detachListeners`) stand.
|
||||
|
||||
## Problem
|
||||
|
||||
The TUI header subtitle came from a `welcome` config the demo leaf set to "TUI agent ready. Give it a coding task." — instructional filler that told a returning user nothing, restated what the product is on every boot, and had a hardcoded twin (`'ready.'`) as the schema default in two packages. The product wanted a startup moment with some character instead of a static banner caption.
|
||||
|
||||
## Decision
|
||||
|
||||
- `examples/tui-agent/cordis.yml` no longer configures `welcome`; the config key stays for deployments and fixtures that need a fixed, deterministic subtitle (the Code Mode overlay and every snapshot/scripted fixture keep theirs).
|
||||
- When `welcome` is unset, `dsh-tui` picks one member of an exported `STARTUP_SLOGANS` bank per boot (`pickStartupSlogan`, injectable random source) and reveals it with a typewriter animation: one character per 40 ms frame, a `▌` block cursor trailing until complete. The reveal starts only after `ui.start()` succeeds and its interval is cleared on dispose alongside the other listeners.
|
||||
- The slogan bank is presentation copy, deliberately not config: deployments that want controlled wording already have `welcome`. Slogans are ASCII-only by contract because the reveal slices per character.
|
||||
- `dsh-tui-demo` forwards `welcome` only when configured instead of defaulting it, so the app no longer decides the TUI's idle subtitle.
|
||||
- The keyless PTY boot scenario now waits for the reveal cursor (`▌` — the only source of that glyph in an empty transcript) instead of the removed welcome text.
|
||||
|
||||
The same change restores `packages/ui/tui/src/index.ts` to 100 % per-file coverage, which the color-scheme merge had broken on the integration branch: the editor border-color reassignment inside `applyColorScheme` was dead (the `setStatus` call right after re-derives it) and is removed, and the color-scheme query's `.then`/`.catch` arrows became named, tested handlers (`applyReportedScheme`, `ignoreSchemeQueryFailure` — the latter pinned by a test whose terminal throws on the DSR query write).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A fixed cooler slogan.** Rejected: one string re-read on every boot decays into wallpaper exactly like the line it replaces; a small rotating bank keeps the moment alive at no complexity cost.
|
||||
|
||||
**Making the bank and reveal speed configurable.** Rejected: that is two new knobs for presentation copy; `welcome` is already the escape hatch for deployments with an opinion, and the no-hardcoded-tunables rule targets deployment-varying behavior, not brand copy.
|
||||
|
||||
**Animating in `HeaderComponent` itself.** Rejected: the component would need a TUI handle and its own lifecycle; the chat already owns a render loop, timers, and a disposal path, so the reveal lives beside the other `createTuiChat` effects and `detachListeners` clears it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Boot output is no longer byte-deterministic when `welcome` is unset (random slogan, timed frames). Every recorded or snapshot surface pins `welcome` explicitly, so no snapshot changed; the PTY smoke anchors on the reveal cursor and the session-id line instead.
|
||||
- The `welcome` schema default disappeared from both `dsh-tui` and `dsh-tui-demo`; a direct caller passing no welcome now gets a slogan, not `'ready.'`.
|
||||
- Adding a slogan is a one-line bank edit; tests assert membership, not specific text.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins deterministic bank selection with an injected random source, the reveal (a bank member fully rendered, cursor frames observed), the configured-welcome path rendering verbatim with no cursor, and dispose stopping a mid-reveal animation. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real tree in a PTY and waits on the reveal cursor. Verified live in tmux (mid-reveal frame `no map below▌` then the full slogan).
|
||||
@@ -1,39 +0,0 @@
|
||||
# Agent Note: 启动 slogan 取代配置化的 TUI 欢迎语
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-20-tui-startup-slogans.md) | 中文
|
||||
|
||||
> **已被取代**:slogan/动画的那一半由[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md)取代:slogan 库和打字机动画上线后实际使用中显得怪异,已替换为无副标题的横幅加整体扫入。移除示例配置中欢迎语的决定与动画生命周期基础设施(`ui.start()` 后启动、经 `detachListeners` 清除)保持不变。
|
||||
|
||||
## Problem
|
||||
|
||||
TUI 头部副标题来自一个 `welcome` 配置,示例叶子配置把它设为 "TUI agent ready. Give it a coding task."——一句说明书式的填充语,对老用户毫无信息量,每次启动都在复述产品是什么,而且它还有一个硬编码的孪生兄弟(`'ready.'`)作为两个包里的 schema 默认值。产品需要的是一个有性格的启动时刻,而不是一条静态横幅说明。
|
||||
|
||||
## Decision
|
||||
|
||||
- `examples/tui-agent/cordis.yml` 不再配置 `welcome`;该配置键保留给需要固定、确定性副标题的部署与 fixture(Code Mode overlay 和所有快照/脚本化 fixture 都保留各自的欢迎语)。
|
||||
- `welcome` 未设置时,`dsh-tui` 每次启动从导出的 `STARTUP_SLOGANS` 库里挑选一条(`pickStartupSlogan`,随机源可注入),并以打字机动画逐字显示:每帧 40 ms 一个字符,完成前尾随一个 `▌` 块状光标。动画只在 `ui.start()` 成功后启动,其定时器与其他监听器一起在 dispose 时清除。
|
||||
- slogan 库是展示文案,刻意不做成配置:想控制措辞的部署已经有 `welcome` 这个出口。按契约 slogan 只含 ASCII,因为逐字显示按字符切片。
|
||||
- `dsh-tui-demo` 只在配置了 `welcome` 时才转发它,不再填默认值,应用不再替 TUI 决定空闲副标题。
|
||||
- 无 key 的 PTY 启动场景改为等待逐字显示的光标(`▌`——空 transcript 里该字形的唯一来源),不再等待已删除的欢迎文本。
|
||||
|
||||
同一变更把 `packages/ui/tui/src/index.ts` 恢复到 100% 的单文件覆盖率(颜色方案合并曾在集成分支上破坏它):`applyColorScheme` 里对编辑器边框颜色的重新赋值是死代码(紧随其后的 `setStatus` 调用会重新推导它),已删除;颜色方案查询的 `.then`/`.catch` 箭头函数改为具名、有测试的处理器(`applyReportedScheme`、`ignoreSchemeQueryFailure`——后者由一个让终端在 DSR 查询写入时抛错的测试固定)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**换一条更酷的固定 slogan。** 否决:一条每次启动都重读的字符串会和它取代的那行一样退化成墙纸;一个小的轮换库以零复杂度代价让这个时刻保持新鲜。
|
||||
|
||||
**把 slogan 库和显示速度做成配置。** 否决:那是为展示文案新增两个旋钮;对措辞有主张的部署已经有 `welcome` 这个出口,而「插件里不许硬编码可调参数」规则针对的是随部署变化的行为,不是品牌文案。
|
||||
|
||||
**在 `HeaderComponent` 内部做动画。** 否决:组件将需要持有 TUI 句柄和自己的生命周期;聊天层已经拥有渲染循环、定时器和释放路径,所以逐字显示与 `createTuiChat` 的其他资源放在一起,由 `detachListeners` 清除。
|
||||
|
||||
## Consequences
|
||||
|
||||
- `welcome` 未设置时启动输出不再字节级确定(随机 slogan、定时帧)。所有录制或快照表面都显式固定 `welcome`,因此没有快照变化;PTY 冒烟测试改为锚定逐字显示光标和会话 id 行。
|
||||
- `welcome` 的 schema 默认值从 `dsh-tui` 和 `dsh-tui-demo` 中消失;不传 welcome 的直接调用方现在得到的是 slogan,而不是 `'ready.'`。
|
||||
- 新增一条 slogan 只需在库里加一行;测试断言成员归属,不断言具体文本。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定以下行为:注入随机源后的确定性选取、逐字显示(库中某条完整渲染、观察到光标帧)、配置了 welcome 时逐字动画不启动且原文渲染、以及 dispose 停止进行中的动画。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里启动真实配置树并等待显示光标。已在 tmux 中实机验证(中途帧 `no map below▌`,随后是完整 slogan)。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-dsh-system-prompt-source-path.md: b54d01488fd7c0b49e06200c93af2b056c9fd00b
|
||||
2026-07-21-dsh-system-prompt-source-path.zh.md: 208e3dce072f63c280999e15276dce62ff4e5c43
|
||||
2026-07-21-dsh-system-prompt-source-path.md: 4cb89e8124840bba6633235d195e95957245137c
|
||||
2026-07-21-dsh-system-prompt-source-path.zh.md: 90c23bed4a3f95155e323c63a68fe2da09543ea6
|
||||
|
||||
@@ -16,7 +16,7 @@ The testable logic lives in `dsh-app-boot`, not in `apps/cli`, because `apps/*`
|
||||
|
||||
## Scope
|
||||
|
||||
Only the `dsh` CLI adds this. The demo bins (`dsh-tui-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs.
|
||||
Only the `dsh` CLI adds this. The demo bins (`dsh-cli-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs.
|
||||
|
||||
## HMR
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
## Scope
|
||||
|
||||
只有 `dsh` CLI 会加入这一段。demo bin(`dsh-tui-demo`、`dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。
|
||||
只有 `dsh` CLI 会加入这一段。demo bin(`dsh-cli-demo`、`dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。
|
||||
|
||||
## HMR
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# Agent Note: Auto-titled terminal from the first message
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-auto-pane-title.zh.md)
|
||||
|
||||
> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events.
|
||||
|
||||
> **Superseded** for the default and the resume behavior by the [auto-title default-on Agent Note](2026-07-21-tui-auto-title-default-on.md): `autoTitle` now defaults on, and a resumed session re-derives its title from the stored first message instead of keeping the static one. The OSC 0 path, the one-shot latch, the model-summary shape, the fire-and-forget call, and every failure fallback below stand.
|
||||
|
||||
## Problem
|
||||
|
||||
The TUI's terminal title is a single static string (`title`, default `DeepSeek Harness`) shared by every session. A user who runs one agent per tmux pane or terminal tab sees the same label on all of them, so panes are indistinguishable at a glance and the tab bar carries no signal about what each session is doing.
|
||||
|
||||
## Decision
|
||||
|
||||
- `TuiConfig` gains an `autoTitle` boolean (default `false`). When it is on, the TUI issues one background model call after the first user message of a fresh session and replaces the terminal title with a short, model-generated label; the static `title` is the pre-title and the fallback.
|
||||
- The label is a model summary, not a truncation of the prompt. The request carries a fixed task instruction (summarize the request as a short title of two to five lowercase words, no punctuation) plus the user's first message and no tools; the TUI takes the first non-empty line of the reply and caps it at 40 characters (39 plus an ellipsis).
|
||||
- The title is set through `runtime.terminal.setTitle`, the same OSC 0 path the static `title` already uses. No new terminal-control surface is introduced, and pi-tui keeps ownership of terminal writes.
|
||||
- The call is fire-and-forget and one-shot per session. A `titleSettled` latch guards it: with `autoTitle` off it is pre-settled and never runs; on a resumed session whose first `user/message` is already logged it is pre-settled so the static title stands; a whitespace-only first message is skipped without consuming the slot. Any failure, an empty reply, a missing `llm` service, or a missing agent provider/model leaves the static title untouched. A dedicated `AbortController` cancels an in-flight request on shutdown.
|
||||
- The title call reaches `ctx.llm.stream` directly rather than through `agent.send`, so it never appends to the session or transcript and cannot perturb the agent loop.
|
||||
- The feature defaults off and is enabled only in the interactive product config (`examples/tui-agent/cordis.yml`) and the scripted PTY fixture. Enabling it in the shared `dsh-tui-demo` schema default would fire an extra model call in keyless replay and boot scenarios that send no user message.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Truncate the first user message instead of a model title.** Rejected: the user chose a short model-made label; a truncated raw prompt is noisy, often begins with boilerplate, and rarely reads as a title.
|
||||
|
||||
**Rename the window (OSC 2) or the tmux window.** Rejected: OSC 0 sets only `pane_title`, so it labels the pane without renaming or leaking into the user's window title; the user confirmed OSC is the right lever.
|
||||
|
||||
**Default the feature on.** Rejected: enabling it in the shared demo schema perturbs keyless replay and boot snapshots and spends a model call on every fresh session; opt-in per deployment keeps the default surface inert.
|
||||
|
||||
**Fold this into the log-backed session-title work (PR #451).** Rejected: that change is session metadata persisted to the log; this is a terminal label with no persistence. Keeping them independent leaves each self-contained and avoids a shared dependency.
|
||||
|
||||
**Block the first turn until the title resolves.** Rejected: awaiting the title before sending the user's message adds latency to the actual request; fire-and-forget makes the rename invisible to the turn.
|
||||
|
||||
## Consequences
|
||||
|
||||
- When enabled, a fresh session spends one extra, tool-less model call with a single short user message and a few output tokens; off by default, it costs nothing.
|
||||
- Because the title call stamps `sessionId`, it shares the session's `llm-replay` cursor: enabling `autoTitle` in a replay-backed snapshot scenario would consume a recorded script entry. This is why the default is off and the scripted PTY fixture answers the call with a tool-branching adapter rather than replay.
|
||||
- `packages/ui/tui/tests/tui.spec.ts` pins the behavior with a mock `llm` adapter: a generated title replaces the static one, over-long output is truncated with an ellipsis, a whitespace-only first message keeps the one-shot slot, empty or failing replies leave the title, a resumed session never fires, and the feature-off / no-service / missing-provider / missing-model paths keep the static title. A shutdown test asserts the in-flight request is aborted.
|
||||
- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` proves the real Loader-booted path: the scripted adapter answers the tool-less title call with a fixed string, and the conversation scenario asserts the OSC 0 sequence reaches the PTY. Boot scenarios send no user message, so they never fire the call.
|
||||
@@ -1,41 +0,0 @@
|
||||
# Agent Note: 从首条消息自动命名终端
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-auto-pane-title.md) | 中文
|
||||
|
||||
> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。
|
||||
|
||||
> **已被取代**(就默认值与恢复行为而言),见[自动标题默认开启 Agent Note](2026-07-21-tui-auto-title-default-on.md):`autoTitle` 现默认开启,恢复会话会从已存储的首条消息重新推导标题,而非保留静态标题。下文的 OSC 0 路径、一次性门闩、模型概括形态、发出后不等待其返回的调用,以及每一条失败兜底,均仍然成立。
|
||||
|
||||
## Problem
|
||||
|
||||
TUI 的终端标题是一个所有会话共用的静态字符串(`title`,默认 `DeepSeek Harness`)。在 tmux 每个窗格或每个终端标签页各跑一个 agent(智能体)的用户看来,它们的标签全都一样,因此窗格一眼看去无从区分,标签栏也不携带任何关于各会话正在做什么的信号。
|
||||
|
||||
## Decision
|
||||
|
||||
- `TuiConfig` 新增布尔字段 `autoTitle`(默认 `false`)。开启后,TUI 会在全新会话的首条用户消息之后发起一次后台模型调用,并用一个简短的、模型生成的标签替换终端标题;静态 `title` 是替换前的初值,也是兜底。
|
||||
- 该标签是模型概括,而非对提示词的截断。请求携带一段固定的任务指令(将该请求概括为两到五个小写单词、不含标点的简短标题)加上用户的首条消息,且不带工具;TUI 取回复的首个非空行并截断到 40 个字符(39 个字符加一个省略号)。
|
||||
- 标题通过 `runtime.terminal.setTitle` 设置——静态 `title` 已经在用的同一条 OSC 0 路径。不引入任何新的终端控制面,终端写入仍归 pi-tui 所有。
|
||||
- 该调用发出后不等待其返回,且每会话仅一次。一个 `titleSettled` 门闩守护它:`autoTitle` 关闭时它预先置为已结算、从不运行;在首条 `user/message` 已入日志的恢复会话中它预先结算,因此静态标题得以保留;仅含空白的首条消息被跳过且不消耗名额。任何失败、空回复、缺少 `llm` 服务、或缺少 agent 的 `provider` 或 `model`,都会让静态标题保持不动。一个专用的 `AbortController` 在关闭时取消尚在进行的请求。
|
||||
- 标题调用直接抵达 `ctx.llm.stream`,而非经由 `agent.send`,因此它从不追加进会话或 transcript(文本记录),也无法扰动 agent loop(智能体循环)。
|
||||
- 该功能默认关闭,仅在交互式产品配置(`examples/tui-agent/cordis.yml`)与脚本化 PTY fixture(测试前置数据)中开启。若在共享的 `dsh-tui-demo` schema 默认值里开启,会在不发送任何用户消息的无密钥回放与启动场景中多发一次模型调用。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**截断首条用户消息,而非用模型生成标题。** 否决:用户选择的是简短的、模型制作的标签;截断后的原始提示词嘈杂、常以样板文字开头,且很少读起来像标题。
|
||||
|
||||
**重命名窗口(OSC 2)或 tmux 窗口。** 否决:OSC 0 只设置 `pane_title`,因此它标记窗格而不重命名、也不泄漏进用户的窗口标题;用户确认 OSC 是正确的手段。
|
||||
|
||||
**让该功能默认开启。** 否决:在共享的 demo schema 里开启会扰动无密钥回放与启动快照,并在每个全新会话上花掉一次模型调用;按部署选择性开启可让默认面保持惰性。
|
||||
|
||||
**并入日志支撑的会话标题工作(PR #451)。** 否决:那项改动是持久化到日志的会话元数据;本项是不做持久化的终端标签。让二者相互独立可使各自自成一体,并避免共享依赖。
|
||||
|
||||
**阻塞首轮直到标题就绪。** 否决:在发送用户消息前先等待标题,会给实际请求增加延迟;发出后不等待其返回可让重命名对该轮次不可见。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 开启时,全新会话会多花一次无工具的模型调用,只带单条简短的用户消息和少量输出 token;默认关闭时它不产生任何开销。
|
||||
- 由于标题调用会打上 `sessionId`,它与会话的 `llm-replay` 游标共享:在以回放支撑的快照场景中开启 `autoTitle` 会消耗一条录制脚本条目。这正是它默认关闭、且脚本化 PTY fixture 用按工具分支的适配器而非回放来回答该调用的原因。
|
||||
- `packages/ui/tui/tests/tui.spec.ts` 用一个 mock `llm` 适配器固定该行为:生成的标题替换静态标题、过长输出以省略号截断、仅含空白的首条消息保留一次性名额、空回复或失败回复保留标题、恢复的会话从不触发,以及功能关闭 / 无服务 / 缺提供方 / 缺模型各路径都保留静态标题。一项关闭测试断言尚在进行的请求被中止。
|
||||
- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 证明真实的经 Loader 启动的路径:脚本化适配器以固定字符串回答无工具的标题调用,对话场景断言 OSC 0 序列抵达 PTY。启动场景不发送用户消息,因此它们从不触发该调用。
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-21-tui-auto-title-default-on.md: 35809e1ef6bade3e09c34b17608eff5f8fb5bd22
|
||||
2026-07-21-tui-auto-title-default-on.zh.md: aa20cfde1359605f2ac5a8f0427f4518c611ecd1
|
||||
@@ -1,32 +0,0 @@
|
||||
# Agent Note: Auto-title on by default, re-derived on resume
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-auto-title-default-on.zh.md)
|
||||
|
||||
> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events.
|
||||
|
||||
## Problem
|
||||
|
||||
The [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) shipped `autoTitle` off by default and, on a resumed session, kept the static title because the first `user/message` was already logged. In use both choices defeated the feature's purpose. A per-session descriptive pane title is what makes one tmux pane or terminal tab distinguishable from the next; leaving it off by default means the product ships an inert feature that almost no user turns on, and skipping re-derivation on resume means a resumed session — exactly the long-lived session most worth labelling — falls back to the shared static string. The user asked for a descriptive per-session name to be the normal experience.
|
||||
|
||||
## Decision
|
||||
|
||||
- `autoTitle` defaults **on** (`z.boolean().default(true)`, mirrored by `resolveTuiConfig`'s `?? true`). A deployment with an `llm` service and an agent provider/model gets a model-made pane title on every session without opting in; one without them keeps the static title, so default-on is inert where the call cannot run.
|
||||
- A **resumed** session re-derives the title on mount from its already-logged first `user/message`: `createTuiChat` scans `agent.session.events` for the first such event and feeds its text to the same one-shot `generateTitle`. The title is never persisted (the session header carries no title field), so it is always derived, never restored.
|
||||
- The one-shot latch is now simply `titleSettled = !resolved.autoTitle`. The prior pre-settle-on-resume clause is gone: on resume `generateTitle` runs once from the stored first message and then latches, so a message that arrives *after* the resume does not re-title. A fresh session has no stored `user/message` at mount, so the resume scan is a no-op and the live `session/event` listener titles the first message instead.
|
||||
- Everything else from the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) stands unchanged: the OSC 0 `runtime.terminal.setTitle` path, the model-summary shape (two-to-five lowercase words, first non-empty line, 40-char cap), the fire-and-forget `ctx.llm.stream` call that never touches the session or transcript, the shutdown `AbortController`, and every failure fallback (empty reply, missing `llm`, missing provider/model, whitespace-only prompt).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the feature off by default.** Rejected: this is a direct reversal of the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md)'s "default off" decision at the user's request. Off-by-default ships an inert feature; the descriptive name is only useful if it is the normal experience. The keyless-replay concern that motivated off-by-default is addressed by pinning `autoTitle: false` in the replay-backed snapshot scenarios rather than by suppressing it for every deployment.
|
||||
|
||||
**Persist the derived title in the session header.** Rejected: the header has no title field and adding one would make a terminal label into session metadata — the boundary the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) already drew against the log-backed session-title work. Re-deriving from the stored first message costs one tool-less call on resume and keeps the label a pure function of the conversation.
|
||||
|
||||
**Re-derive on resume from the latest message instead of the first.** Rejected: the title summarises what the session is *about*, which its opening request captures; a mid-conversation message would make the pane label drift as the work moves on.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A fresh session with a working `llm` now spends one extra tool-less model call by default (previously only when opted in); a resumed session spends one on mount. Deployments without an `llm` or provider/model are unaffected.
|
||||
- The replay-backed `examples/tui-agent/tests/tui.snapshot.ts` must opt **out**: it pins `autoTitle: false`, because a default-on title request is not among the recorded turns and `installLlmReplay` fails loud on an unrecorded request. The unit `packages/ui/tui/tests/tui.snapshot.ts` needs no opt-out — it mounts no `llm` service, so `generateTitle` short-circuits and the default flip is inert there. The interactive `examples/tui-agent/cordis.yml` and the scripted PTY fixture already set `autoTitle: true`, so the keyless smoke's OSC 0 assertion is unchanged.
|
||||
- `packages/ui/tui/tests/tui.spec.ts` pins the new defaults: the config-default test expects `autoTitle: true`; the disabled-path test now sets `autoTitle: false` explicitly; and the former "resumed session never fires" test is rewritten to assert re-derivation from the stored first message and that a later live message does not re-title. `docs/config-catalog.md` regenerates to "On by default".
|
||||
@@ -1,32 +0,0 @@
|
||||
# Agent Note: 自动标题默认开启,恢复时重新推导
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-auto-title-default-on.md) | 中文
|
||||
|
||||
> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。
|
||||
|
||||
## Problem
|
||||
|
||||
[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 交付时 `autoTitle` 默认关闭,并且在恢复会话中因首条 `user/message` 已入日志而保留静态标题。实际使用中这两个选择都违背了该功能的初衷。让一个 tmux 窗格或终端标签页区别于下一个的,正是每会话各异的描述性窗格标题;默认关闭意味着产品交付了一个几乎无人开启的惰性功能,而恢复时不重新推导,则意味着恢复会话——恰恰是最值得标记的长命会话——退回到共用的静态字符串。用户要求把每会话的描述性名称做成常态体验。
|
||||
|
||||
## Decision
|
||||
|
||||
- `autoTitle` 默认**开启**(`z.boolean().default(true)`,`resolveTuiConfig` 以 `?? true` 与之对齐)。带有 `llm` 服务与 agent 提供方/模型的部署无需选择性开启即可在每个会话获得模型制作的窗格标题;不具备它们的部署保留静态标题,因此在调用无法运行处,默认开启是惰性的。
|
||||
- **恢复**会话在挂载时从其已入日志的首条 `user/message` 重新推导标题:`createTuiChat` 在 `agent.session.events` 中扫描首个此类事件,并把其文本喂给同一个一次性的 `generateTitle`。标题从不持久化(会话头不携带标题字段),因此它始终是推导得来,而非恢复而来。
|
||||
- 一次性门闩现在只是 `titleSettled = !resolved.autoTitle`。此前"恢复即预先结算"的分句已删除:恢复时 `generateTitle` 从已存储的首条消息运行一次随后上闩,因此恢复*之后*到达的消息不会再改标题。全新会话在挂载时没有已存储的 `user/message`,因此恢复扫描是空操作,改由实时的 `session/event` 监听器为首条消息命名。
|
||||
- [自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 的其余一切保持不变:OSC 0 的 `runtime.terminal.setTitle` 路径、模型概括形态(两到五个小写单词、首个非空行、40 字符上限)、从不触碰会话或 transcript(文本记录)的发出后不等待其返回的 `ctx.llm.stream` 调用、关闭时的 `AbortController`,以及每一条失败兜底(空回复、缺 `llm`、缺提供方/模型、仅含空白的提示词)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**让该功能保持默认关闭。** 否决:这是应用户要求,对[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)"默认关闭"决策的直接反转。默认关闭交付的是惰性功能;只有当描述性名称成为常态体验时它才有用。当初促成默认关闭的无密钥回放顾虑,改由在以回放支撑的快照场景中固定 `autoTitle: false` 来处理,而非为每个部署都压制该功能。
|
||||
|
||||
**把推导出的标题持久化进会话头。** 否决:会话头没有标题字段,加一个会把终端标签变成会话元数据——正是[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)已经对日志支撑的会话标题工作划出的边界。从已存储的首条消息重新推导,代价是恢复时一次无工具调用,并让标签保持为对话的纯函数。
|
||||
|
||||
**恢复时从最新消息而非首条消息重新推导。** 否决:标题概括的是会话*关于什么*,而这由其开场请求捕获;一条对话中途的消息会让窗格标签随工作推进而漂移。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 带可用 `llm` 的全新会话现在默认多花一次无工具的模型调用(此前只在选择性开启时才有);恢复会话在挂载时花掉一次。不具备 `llm` 或提供方/模型的部署不受影响。
|
||||
- 以回放支撑的 `examples/tui-agent/tests/tui.snapshot.ts` 必须选择**关闭**:它固定 `autoTitle: false`,因为默认开启的标题请求不在录制轮次之列,而 `installLlmReplay` 对未录制的请求会显式报错。单元 `packages/ui/tui/tests/tui.snapshot.ts` 无需选择关闭——它不挂载 `llm` 服务,因此 `generateTitle` 提前短路,默认值的翻转在那里是惰性的。交互式的 `examples/tui-agent/cordis.yml` 与脚本化 PTY fixture(测试前置数据)已设 `autoTitle: true`,因此无密钥冒烟测试的 OSC 0 断言保持不变。
|
||||
- `packages/ui/tui/tests/tui.spec.ts` 固定新的默认值:config 默认测试期望 `autoTitle: true`;关闭路径测试现在显式设 `autoTitle: false`;此前的"恢复会话从不触发"测试改写为断言从已存储首条消息重新推导,并断言之后的实时消息不会再改标题。`docs/config-catalog.md` 重新生成为"On by default"。
|
||||
@@ -1,35 +0,0 @@
|
||||
# Agent Note: The banner sweeps in; the subtitle line is gone
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-tui-banner-sweep.zh.md)
|
||||
|
||||
> **Superseded** by the [no-banner Agent Note](2026-07-21-tui-no-banner.md): the banner itself was removed, taking the sweep with it.
|
||||
|
||||
## Problem
|
||||
|
||||
The [startup-slogans Agent Note](2026-07-20-tui-startup-slogans.md) replaced the instructional welcome line with a random slogan bank revealed by a per-character typewriter. In use the quotes read as weird — random flavor text in a tool's header — and the animation was slow (40 ms/char over a full sentence) while animating only one line of a four-line banner. This note supersedes that decision's slogan half; the removal of the configured demo welcome and the animation-lifecycle groundwork stand.
|
||||
|
||||
## Decision
|
||||
|
||||
- The slogan bank, `pickStartupSlogan`, and the typewriter reveal are deleted. When `welcome` is unset the banner simply has **no subtitle line** — title and model/session detail only. The `welcome` config remains for deployments and fixtures that want a fixed subtitle, rendered frame-deterministically with no animation.
|
||||
- The startup animation is now the **whole banner**: `HeaderComponent` gains a `revealWidth` clip, and the header box wipes in left-to-right over ~24 frames at 15 ms (~360 ms total, ~60 fps), started after `ui.start()` succeeds and cleared through the same `detachListeners` path the typewriter used. `stopBannerReveal` also resets the clip so a disposed-mid-sweep header re-renders whole.
|
||||
- The PTY smoke's boot marker changes from the typewriter cursor (`▌`) to the banner's top-right corner (`╮`), which only renders once the sweep completes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the animation as-is and only change the copy.** Rejected: any fixed or rotating phrase re-read on every boot decays into wallpaper; the user's judgment was that the quotes themselves, not just their content, were wrong for the surface.
|
||||
|
||||
**Animate per banner line (top-down) instead of a left-right sweep.** Rejected: with only four lines the animation would have four visible steps — closer to a flicker than a reveal; the horizontal sweep uses the full terminal width for a smooth motion at the same total duration.
|
||||
|
||||
**Character-level clipping via `revealWidth` on styled text.** Adopted with `truncateToWidth` from pi-tui, the same ANSI-aware clipper the header already uses for width overflow, so the sweep cannot tear escape sequences.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Boot output with `welcome` unset is again animation-dependent but no longer random: every boot sweeps the same banner. Configured welcomes (all snapshot/scripted fixtures, the Code Mode overlay) stay frame-deterministic and unchanged.
|
||||
- The `STARTUP_SLOGANS`/`pickStartupSlogan` exports are gone; no consumer outside the deleted tests referenced them.
|
||||
- The default banner is one line shorter (no subtitle), so PTY assertions anchored on banner geometry use the corner glyph rather than any subtitle text.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` pins: the sweep completes to a full banner (both corners + title) and produced at least one clipped mid-sweep frame; a configured welcome renders verbatim with no clipped frames; the unset-welcome banner has no subtitle; and dispose clears the sweep's own interval handle. The PTY smoke boots on the `╮` completion marker across the tui-demo bin, the dsh CLI, and the personal-overlay scenarios. Verified live in tmux.
|
||||
@@ -1,35 +0,0 @@
|
||||
# Agent Note: 横幅整体扫入;副标题行移除
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-tui-banner-sweep.md) | 中文
|
||||
|
||||
> **已被取代**:由[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md)取代:横幅本身已移除,扫入动画随之移除。
|
||||
|
||||
## Problem
|
||||
|
||||
[启动 slogan Agent Note](2026-07-20-tui-startup-slogans.md) 用随机 slogan 库加逐字打字机动画取代了说明书式的欢迎行。实际使用中这些引语显得怪异——工具头部出现随机的风味文案——而且动画很慢(每字符 40 ms,扫完一整句),却只动画四行横幅中的一行。本 note 取代该决定中 slogan 的那一半;移除示例配置中欢迎语的决定与动画生命周期的基础设施保持不变。
|
||||
|
||||
## Decision
|
||||
|
||||
- 删除 slogan 库、`pickStartupSlogan` 和打字机动画。`welcome` 未设置时横幅直接**没有副标题行**——只有标题和模型/会话详情。`welcome` 配置保留给想要固定副标题的部署与 fixture,无动画、逐帧确定地渲染。
|
||||
- 启动动画现在作用于**整个横幅**:`HeaderComponent` 增加 `revealWidth` 裁剪,头部盒子以约 24 帧、每帧 15 ms(总计约 360 ms、约 60 fps)从左到右扫入,在 `ui.start()` 成功后启动,经打字机动画用过的同一条 `detachListeners` 路径清除。`stopBannerReveal` 同时重置裁剪,因此扫入中途被 dispose 的头部会重新完整渲染。
|
||||
- PTY 冒烟测试的启动标记从打字机光标(`▌`)改为横幅右上角(`╮`),它只在扫入完成后才渲染。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留动画原样、只改文案。** 否决:任何每次启动都被重读的固定或轮换语句都会退化成墙纸;用户的判断是引语本身——而不只是内容——对这个表面来说就是错的。
|
||||
|
||||
**按横幅行逐行(自上而下)动画而非左右扫入。** 否决:只有四行时动画只有四个可见步骤——更像闪烁而不是展开;水平扫入用满终端宽度,在相同总时长内动作更平滑。
|
||||
|
||||
**用 `revealWidth` 对带样式文本做字符级裁剪。** 采用 pi-tui 的 `truncateToWidth`——头部处理宽度溢出时已在使用的同一个 ANSI 感知裁剪器——因此扫入不可能撕裂转义序列。
|
||||
|
||||
## Consequences
|
||||
|
||||
- `welcome` 未设置时启动输出再次依赖动画但不再随机:每次启动扫入同一幅横幅。配置了欢迎语的场景(全部快照/脚本化 fixture、Code Mode overlay)保持逐帧确定且不变。
|
||||
- `STARTUP_SLOGANS`/`pickStartupSlogan` 导出移除;除被删除的测试外没有消费者引用它们。
|
||||
- 默认横幅少一行(无副标题),因此锚定横幅几何的 PTY 断言使用角落字形而非任何副标题文本。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/ui/tui/tests/tui.spec.ts` 固定:扫入完成为完整横幅(两个角 + 标题)且产生了至少一个裁剪的中途帧;配置的欢迎语原文渲染且无裁剪帧;未设置欢迎语的横幅没有副标题;dispose 清除扫入自己的定时器句柄。PTY 冒烟测试在 tui-demo bin、dsh CLI 和个人 overlay 场景中以 `╮` 完成标记启动。已在 tmux 中实机验证。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user