Merge origin/master into codex/provider-retry-policy

This commit is contained in:
Turtle
2026-07-25 10:35:22 +08:00
766 changed files with 19344 additions and 1424 deletions

View File

@@ -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-14-acp-agent-client-protocol.md: c6976ed28a254684fca62e2d309cdde7ea90340d
2026-06-14-acp-agent-client-protocol.zh.md: 6bd234f2aa3f43f4fc6cbd921161253c46aab73e

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-14-acp-agent-client-protocol.zh.md)
## 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.

View File

@@ -0,0 +1,59 @@
# Agent Note: Agent Client ProtocolACP支持——从外部编辑器驱动编码 agent
Status: implemented
[English](2026-06-14-acp-agent-client-protocol.md) | 中文
## 问题
harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本但编辑器无法以结构化方式创建或恢复会话、关联提示词完成、流式输出推理reasoning与工具活动、渲染工具专属 UI、请求权限或在不干扰其他对话的前提下取消某个对话。ACPAgent Client Protocol将这些交互定义为基于 stdio 的 JSON-RPCZed 是用于做出具体兼容性决策的目标客户端。
桥接层必须保持 harness 既有的所有权边界。它不能依赖具体的 agent loop智能体循环不能绕过工具注册表不能在编辑器中执行 shell 命令也不能发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。
## 决策
`@deepseek-ai/dsh-acp` 是位于 `packages/ui/acp` 的 UI/客户端驱动插件。它使用 `@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。
精确的已支持与已推迟的协议行列表见 [`packages/ui/acp/acp-feature-support.md`](../../../../packages/ui/acp/acp-feature-support.md);包 README 是操作契约。
## 曾考虑的替代方案
**在 `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 时自动跳过。

View File

@@ -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-14-acp-multi-session.md: 43aa32a43fef7aa69f5efd45d4c8176da9f459cd
2026-06-14-acp-multi-session.zh.md: 6835f9623b89dc20d6c7b3054365cd9c39402aeb

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-14-acp-multi-session.zh.md)
## Problem
An ACP editor can keep several conversations alive over one agent subprocess. A single-active-session bridge would force extra processes and would not match Zed's client model, which tracks multiple session ids and concurrent loads. Multiplexing introduces isolation risks: events, prompt completion, cancellation, permission prompts, config selections, and predictable background-task ids must never cross session boundaries.

View File

@@ -0,0 +1,47 @@
# Agent Note: 在单个连接上多路复用并发 ACP 会话
Status: implemented
[English](2026-06-14-acp-multi-session.md) | 中文
## 问题
一个 ACPAgent Client Protocol编辑器可以在同一个 agent智能体子进程上保持多个对话。如果桥接层只支持单活跃会话就不得不启动额外进程也无法匹配 Zed 的客户端模型——该模型跟踪多个会话 id 和并发加载。多路复用引入了隔离风险:事件、提示词完成、取消、权限提示、配置选择以及可预测的后台 task id 绝不能跨越会话边界。
## 决策
ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中。agent 作用域的回调使用 `ownedRecord`:在正向 map 中查找 `agent.session.id`,且仅当该记录拥有精确的 agent 对象时才接纳它,使外部的同 id 对象无法冒领会话。一条记录拥有其 agent 句柄、进行中的提示词、活跃的工具调用展示状态、待处理的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id使两个流水线化的加载请求无法构造出重复的 agent不同 id 可以并发加载。
每个 `session/event``agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的提示词。提示词记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end` 到达时结算;来自已取消的前一轮次的迟到 end 不能 resolve 更新的提示词。`session/cancel` 定位到一条记录,只调用该 agent 的队列感知取消路径。
权限归属使用对正向 map 的同一精确 agent 检查。ACP `approval/request` 应答器只向拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值只折叠该会话自身的事件,待处理的空闲切换存储在该记录上,直到下一轮次将其锚定。
后台 bash 任务携带一个不透明的 owner token其值等于所属会话 id。`bash_output``bash_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不能获得访问权。归属信息与执行器任务一起存储,因此工具插件重载不会擦除它。
连接拆除时清空活跃 map将每个待处理的提示词以取消状态结算并并行 dispose资源释放所有 `AgentHandle`。每个句柄停止并等待其循环完成、在仍然附着时刷新会话、注销 agent 并移除会话。拆除操作被 memoize 化,由客户端断连和插件 dispose 共享。
## 协议与工作区作用域
[ACP v1 明确允许一个连接上存在多个并发会话](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24),每个新会话都携带自己的主 `cwd`。本桥实现该会话级多路复用,其中包括[按会话 cwd 决策](../architecture/2026-07-02-fs-per-session-cwd.md)所记录的不同主工作区;它不会为每个会话创建一个 agent 子进程。
一个会话内部的多根项目是另一项可选能力ACP 把[有效根目录定义为主 `cwd` 加 `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367)。[Zed 仅在 agent 公布该能力时发送其余项目工作目录](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1139-L1145),否则会[从会话请求中丢弃这些目录](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1454-L1472)。如其[已知限制](../../../../packages/ui/acp/README.md#known-limitations-and-deferred-work)所记录,桥不公布该能力,并拒绝非空值,因此当前 Zed 多根项目到达桥时只携带第一个工作目录。
[标准传输是每个 stdio 连接一个由编辑器启动的 agent 子进程](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42);多个编辑器连接因此需要多个子进程或自定义传输,而本决策保证的是一个连接内部存在多个会话。在该连接内,`ctx.sandboxPolicy` 把每个会话的 `cwd` 解析为其自己的 `workspace-write` 根目录,因此共享的 bash 和文件系统服务可以服务并发项目而不授予跨项目写入。这不会添加 ACP `additionalDirectories`;它只是从已经支持的「每会话一个主根目录」路径中移除了进程级根目录限制。
## 曾考虑的替代方案
**每连接单活跃会话**:否决。增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求。
**每会话 `ctx.extend()`**:否决。子上下文本身不会创建子插件 fiber因此监听器仍属于桥接层 fiber。实际实现的桥接层使用全局监听器加显式 O(1) 解复用以及每会话拥有的记录agent 生命周期由 `AgentHandle` 管理。
**以 Agent 对象标识作为 bash 任务归属**:否决。恢复或替换后的 agent 对象可能合法地代表同一个持久会话。不透明的会话 token 才是跨边界的标识,应当在插件重载后仍然存活。
## 后果
N 个会话可以并发地进行流式输出、提示词、权限请求、配置切换和后台任务运行,而不会交错或跨会话结算。一个会话中的取消或 dispose 不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不会为每个会话添加一组监听器,从而避免了长连接期间的监听器扇出。
桥接层目前仍未暴露独立关闭单个活跃会话的协议方法。当前所有记录在连接拆除时一起离开;会话关闭/恢复的生命周期能力在 ACP 功能清单中仍处于延期状态。
## 验证
多会话测试套件通过交错更新、独立的进行中提示词、定向取消、相同 id 与不同 id 的加载竞争、权限路由、配置隔离以及拆除来驱动并发会话。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。

View File

@@ -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-15-code-mode.md: 94a95ba2c5760c0fed6ebfe09330546f401c42c4
2026-06-15-code-mode.zh.md: 0ba5d69258bce23a24db9a0b470ddb074840cba7

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-15-code-mode.zh.md)
## Problem
In the registry's native presentation, the agent loop advertises every visible capability as a JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../../docs/architecture.md)), with **every** intermediate `tool-result` re-entering the model's context on the next request.

View File

@@ -0,0 +1,133 @@
# Agent Note: Code Mode——模型针对工具注册表编写 TypeScript
Status: implemented
[English](2026-06-15-code-mode.md) | 中文
## 问题
在注册表的原生呈现方式下agent loop智能体循环将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式wire format也记录在请求头日志中模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行是 `dsh-tools` 和 [docs/architecture.md](../../../../docs/architecture.md) 中明确标注的 open TODO且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。
对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。
Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案基于一个简单的观察LLM大语言模型编写代码的能力优于发出工具调用因为它们见过数百万行真实代码而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只策展返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。
工具呈现属于掌管工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall瀑布式事件变换正确性将依赖监听器顺序并与[可重建请求](../architecture/2026-07-05-reconstructable-requests.md)冲突。执行基底同样属于基础设施而非占位实现Node `worker_threads` 提供独立 isolate、空环境、堆上限以及对热同步循环的终止能力同时契合 harness 既有的信任模型(§信任姿态)。
## 决策
三项决策,各自在下方独立小节中展开:
1. **Code Mode 是 `ToolRegistry``dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK。注册表在源头塑造其权威贡献协作式提示词组装的结果仍具权威性记录在日志中的请求头精确反映该返回的呈现。
2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含接口包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`[能力 seam](../architecture/2026-06-13-capability-seams.md);消费方 = `dsh-tools`core 消费 seam 的先例见 `agent-loop``dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个实现包,而非重新设计。
3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。
本说明负责定义 Code Mode 的呈现、组合、隔离与结算基础。后续的[类型化工具返回值 Agent Note](2026-07-20-code-mode-typed-tool-returns.md)负责定义生成的输出映射、规范绑定值、`ToolCallError` 和无损外层输出边界。
### 注册表拥有模式
`ToolRegistry` 获得一个经 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'code' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 翻转模式(`tools: { mode: code }`),无需改代码,遵循 no-hardcoded-tunables 约定。
**协议工具列表。** 注册表在 `'native'` 下贡献可见能力,在 `'code'` 下仅贡献 `run_code`,在 `'both'` 下两者都贡献。最终的 `PromptAssembly.tools` 列表记录在请求头中。`run_code` 是一个保留的呈现传输通道,位于注册和限制层之外;直接提示词提供方和组装 waterfall 仍各自负责自己的贡献。
**与 `toolOrder` 的交互,预先说明:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'code'` 下会拒绝所有组装,因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug使用 Code Mode 的部署需要更新其 order 配置或移除它。
**SDK 提示词段。**`'code'``'both'`tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。
**组装所有权。** `run_code``tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。
**代码生成。** `jsonSchemaToTs()``defineTool` 的 JSON Schema 子集映射为 TypeScript将 schema 描述带入 JSDoc不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。
### run_code 工具与分发桥
`'code'``'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`
1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/code-dispatch`。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。
2. **运行程序**`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。
3. **完全停稳后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的日志和完成值,将其作为规范输出;注册表再把该值渲染为持久化的 `tool/result.content`,供结果卡片直接读取。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。
**子调用上下文通过父调用延后。**`run_code` 内部注入会破坏父调用/结果的相邻性,因此 `ToolRunContext.deferContext()` 按分发顺序收集每个子结果的 `additionalContexts` 条目。即使程序后来抛出异常,注册表仍携带该数组;循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute 阻止会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。
**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。
**呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.md)在此决定:`presentCall` 创建一个 `generic` 卡片,`kind: 'execute'`,以程序文本作为标题,并将同一程序文本作为 `rawInput``run_code` 有意不声明 `presentResult`,因此 ACP 和 TUI 会通过通用原始内容回退机制,使用最终持久化的 `tool/result.content` 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy 输出落盘预览。程序作为标题是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见[结果卡片完整性说明](../bug-fix/2026-07-20-code-mode-result-card-completeness.md)。
### 可观测性:`tool/code-dispatch`
每次子分发追加一个仅日志的 `tool/code-dispatch` 事件,包含父子 call id、工具标识、规范化参数和结果摘要。它不进入模型历史但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。
### code-runtime seam
`packages/code-runtime/code-runtime/`——`@deepseek-ai/dsh-code-runtime`,仅依赖 `cordis`。一个抽象的 `CodeRuntime extends Service``super(ctx, 'codeRuntime')`)加上词汇:
- `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }`
- `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<CodeJsonValue>>; errorClass?: { name: string; memberNameProperty: string } }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;可选描述符要求运行时注入真正的、程序可见的 reject 类,而无需让 seam 获知消费方专用名称。`CodeJsonValue` 是这个低依赖 seam 的结构化无损 JSON 类型,因此绑定参数与解析值可以完整跨越实现的序列化边界。
- `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }`——程序执行失败时,执行 promise 仍会 fulfill并通过 `error` 字段返回失败结果。只有调用方/seam 误用(例如重复的绑定命名空间)时,`run()` 才会 reject消费方仍在自己的错误边界处理不合规后端的拒绝。
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }`——按[防御性模式](../../../../docs/defensive-patterns.md)独立报告的正交结果;超时的 run 不是异常abort 不是超时,有损完成值不是溢出,基底退出也与上述情况相互独立。
- 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——交付的后端为 `'typescript'`Python 后端会声明自己,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'``'container'` 等)。`dsh-tools` 在 MVP 中要求 `language === 'typescript'`——其代码生成输出 TS——否则组装会大声失败`toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。
请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。
### worker-thread 运行时
`@deepseek-ai/dsh-code-runtime-worker``packages/code-runtime/` 组的第二个包package。每次 `run()`
1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes``node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且保持位置不变,因此运行时错误行号与模型源码一致)。仅剥离模式拒绝不可擦除的语法(`enum`、namespaces——该拒绝以 `error.kind: 'exception'` 加 Node 的消息返回SDK 说明写明「仅限可擦除 TypeScript」模型像处理其他程序错误一样自我修正。语法级失败不会 spawn worker。
2. **每次 run spawn 一个全新 `Worker`**,来自包自身的 bootstrap 模块:`env: {}`(真正为空——比 spawn 命令的 scrubbed-env 规则更严格),`resourceLimits` 来自配置,`stdout`/`stderr` 捕获到 `logs` 而非继承。不做池化,不跨 run 保留状态:程序的世界随 worker 消亡,这使得 run 仅从日志即可重建,状态泄漏不可表达。
3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量、消费方声明的 reject 类和一个捕获式 `console` shim因此顶层 `await``return` 可用。Code Mode 声明 `ToolCallError`,成员属性为 `toolName`;运行时无需硬编码工具即可实体化真正的构造函数。无损 JSON 完成值会精确跨越边界;`undefined` 仍表示缺席,有损值产生 `invalid-output`,过大的外层结果产生 `output-limit`,而不会退化为检查格式化后的字符串替代品。
4. **通过消息端口桥接绑定**worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复;宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }``{ id, ok: false, message }`(宿主侧绑定拒绝变为程序侧 rejection。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype因此名为 `__proto__``constructor``toString` 的绑定是普通自有属性,而非原型链碰撞。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是恶意的,因为对端运行的是模型代码。
5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 约束总经过时间,包括未解析的等待。`maxOutputBytes` 只约束序列化后的外层日志、完成值或诊断的组合;中间绑定值没有字节数上限。到期、取消和完成都终止 worker堆退出或外层溢出会作为显式失败报告。
6. **dispose 至完全停稳**:服务自身的 dispose资源释放终止进行中的 worker 并*等待*其退出后再 resolve遵循[防御性模式](../../../../docs/defensive-patterns.md)。
### 信任姿态
worker 运行时提供的是隔离,而非安全边界:模型代码可以访问 Node API权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门禁,并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。
### 模型看到的内容
SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。
## 后果
切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发保持序列化,而每次调用的上下文会通过外层结果保留其 source、信封与元数据。
## 测试
- **Worker 运行时:** 真实 worker 测试覆盖类型化的绑定值与失败、每一种无损 JSON 根类型的完成值、无效和超限输出、精确的组合账本边界、compute 和 wall 预算、恶意绑定流量、空环境以及 dispose 至完全停稳。一个构建后包测试在纯 Node 下运行 worker 入口。
- **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、成功与失败程序中的有序上下文延后、外层阻止抑制以及 HMR热模块替换清理。
- **带密钥 e2e** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 Code Mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。
- **快照:** `code-mode-turn``both-mode-turn``code-mode-workspace-context` fixture测试前置数据固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。
## 曾考虑的替代方案
**一个零核心改动的附加消费方插件。** 否决,因为 `agent/request` 在[可重建请求](../architecture/2026-07-05-reconstructable-requests.md)下仅限 call-config而变换已组装的工具列表需要在不拥有其配置的情况下撤销 `toolOrder` 规范化,并依赖监听器顺序。向模型提供哪些工具、以何种表示形式提供,是注册表的单一关注点:原生 schema 和 SDK 是同一个可见存储的两种投影。
**`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm且无法中断热循环。worker 线程提供独立 isolate、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash因此参考实现和生产实现是同一个包无需 unsafe-acknowledgement 仪式。
**在原生工具调用上做结果省略/摘要。** 仅解决问题的上下文膨胀一半:裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。
**循环中的并行原生分发。** 往返成本的另一个答案仍是有效的未来工作open TODO仍被并发安全元数据阻塞且仍无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策保持两者兼容:当元数据就绪时,原生并行分发和每工具绑定并行化一起解锁。
**始终排他(忠于 Cloudflare无模式** 否决,因为本 SDK 的主要消费方是编码 agent其日常的单次调用`bash``read``edit`作为原生调用已经是最优的强制每次编辑都通过程序会给常见场景增加负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用,而不强加于人。
**每工具可见性分层(此工具 native彼工具 code-only** 推迟:它需要每工具元数据和 `'native' | 'code' | 'both'` 不提供的呈现拆分,且其设计取决于模型在 `'both'` 下如何分配使用的证据。
**SDK 中的清洁化标识符别名**`my-tool``my_tool`Cloudflare 的做法)。否决:`declare const` 上的带引号键使每个名称可达,零别名碰撞逻辑;模型能正常处理 `tools["my-tool"](…)`
**REPL 风格的持久内核**(状态跨 `run_code` 调用存活)。在 MVP 中否决:跨调用状态对会话日志不可见,破坏了「每个请求是日志的纯函数」这一可重建性保证;每次 run 全新保持了这一点。内核风格的后端在未来仍可通过同一 seam 表达,配合自己的日志方案。
## 风险
**Worker 不是硬安全边界。** 有意为之且已文档化(§信任姿态):姿态等同于既有的 bash 工具,隔离程度超过它,门禁使用相同的 seam。需要更强隔离的部署需要未来的 `isolation: 'container'` 后端——作为 seam 设计的扩展点跟踪,而非本设计的 TODO。
**`stripTypeScriptTypes` 标记为 experimental。** 它与 Node 自身原生 `.ts` 执行背后的引擎amaro/swc相同在本仓库的整个引擎范围内作为 API 暴露。缓解措施:运行时的单元测试套件固定了所依赖的行为(位置保持、可擦除限制的拒绝消息形状宽松匹配),调用位于一个私有函数之后,且 `amaro`/`sucrase` 是 API 变化时的直接替代品。仅可擦除子集是面向模型的契约线,错误路径是一个可工作的反馈循环,而非死胡同。
**SDK 的提示词成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本mode 是每部署的;本 Agent Note 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。
**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。通过包内的模块边界(`ts-types.ts``code-mode.ts``schema.ts`/`json-schema.ts`/`presentation.ts` 并列)和 seam 约束:所有基底相关的内容都在 `ctx.codeRuntime` 之后。
**大型无损 JSON 值可能耗尽内存。** 工具绑定会在分发前对无损 JSON 创建快照,并完整返回规范 JSON 解析值。运行时会校验 worker 端口两侧,但不对单次绑定设置字节数上限;结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。
**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的并发安全元数据绑定。
**预算计量读取事件循环,而非 flag。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗糙——预算到期最多延迟一个轮询间隔——且其正确性声明「pending 的分发不能暂停它」)对恶意程序是承重的。两侧都有单元测试(带 pending 诱饵分发的热循环在 `computeMs` 处死亡;在慢绑定上空闲的程序存活到 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段。

View File

@@ -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-17-filesystem-tool-schemas.md: 9941b3916b361a916c8148eb099eb8cfd46371c8
2026-06-17-filesystem-tool-schemas.zh.md: 47e43c47db83b1fcc292b17cf0113d9abd9293d1

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-17-filesystem-tool-schemas.zh.md)
## Problem
[The filesystem capability-seam Agent Note](../architecture/2026-06-17-filesystem-capability-seam.md) defines the filesystem capability seam (`ctx.fs`), the package split (`dsh-fs`, `dsh-fs-local`, `dsh-tool-fs`, plus the `dsh-fs-policy` policy plugin), and the observed-file/stale-version policy for read-before-write/edit checks — which the [split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) Agent Notes moved off `ctx.fs` into the `dsh-fs-policy` plugin on the `fs/*` event gate. The remaining decision for the first filesystem tool delivery is the model-facing schema surface: what arguments the model sees for `read`, `write`, and `edit`.

View File

@@ -0,0 +1,112 @@
# Agent Note: 文件系统工具 schema——面向模型的读/写/编辑接口形状
Status: implemented
[English](2026-06-17-filesystem-tool-schemas.md) | 中文
## 问题
[文件系统能力 seam Agent Note](../architecture/2026-06-17-filesystem-capability-seam.md) 定义了文件系统能力 seam`ctx.fs`、包package拆分`dsh-fs``dsh-fs-local``dsh-tool-fs`,加上 `dsh-fs-policy` 策略插件),以及针对 read-before-write/edit 检查的 observed-file/stale-version 策略——[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.md)和[事件门控插件](../architecture/2026-06-26-file-context-as-event-gate.md) Agent Note 后来将其从 `ctx.fs` 移至 `dsh-fs-policy` 插件的 `fs/*` 事件门上。首次文件系统工具交付剩余的决策是面向模型的 schema 接口:模型在 `read``write``edit` 中看到哪些参数。
该 schema 应足够小,以便在 `dsh-tool-fs` 的首次实现中完成,但又足够稳定,使未来的本地/远程/沙箱文件系统后端不需要改动面向模型的接口。同时应避免从参考系统中照搬所有选项。Claude Code 和 OpenCode 暴露了类似的核心文件工具,但在命名风格和额外 flag 上有所不同;本 Agent Note 为原型选择最小的共有接口。
## 决策
`@deepseek-ai/dsh-tool-fs` 在首个文件系统工具套件中暴露以下三个面向模型的工具:
| 工具 | 我们的 schema | Claude Code | OpenCode | 说明 | 原型包含 |
|---|---|---|---|---|---|
| `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | 仅文件;`offset` 从 1 开始首版不支持图片、PDF 或多模态内容。 | 是 |
| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | 创建或覆盖 UTF-8 文本。在默认 fs-policy 下,更新现有文件前必须先观测;创建新文件则不需要。 | 是 |
| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | 字面字符串替换;默认要求唯一匹配;在默认 fs-policy 下必须先观测(任意窗口读取均算作观测)。 | 是 |
schema 使用 snake_case 字段名(`file_path``old_string``new_string``replace_all`),与 Claude Code 及现有 DeepSeek Harness 工具 schema 示例保持一致。消费方包将这些面向模型的名称转换为 `ctx.fs` 调用和 `fs/*` 事件分发。
## 工具 schema
### `read`
`read` 检视一个 UTF-8 文本文件并返回带行号的内容。
参数:
- `file_path: string`——必填。要读取的路径,由 `ctx.fs` 解析。
- `offset?: number`——可选。返回的第一行,从 1 开始。默认为第一行。
- `limit?: number`——可选。返回的最大行数。默认值与上限是 `dsh-tool-fs` / `ctx.fs` 的实现细节。
首次实现不涉及的内容:
- 无 PDF `pages` 参数。
- 无图片或多模态文件读取。
- 不通过 `read` 列出目录;如有需要,目录列表将作为单独的后续工具。
### `write`
`write` 创建或完整替换一个 UTF-8 文本文件。
参数:
- `file_path: string`——必填。要写入的路径,由 `ctx.fs` 解析。
- `content: string`——必填。要写入的完整 UTF-8 文本内容。
在默认 fs-policy 下,使用 `write` 更新已有文件需要同一执行上下文先前对该文件有过一次观测read/write/edit`dsh-fs-policy` 插件将观测到的版本作为 `fs/write-intent` 上的 stale guard 提供。创建新文件不需要先前观测。如果策略插件不存在,`write` 是无条件的裸提供方 create-or-overwrite。
schema 不将 `expected_hash``expected_version``create_only` 作为面向模型的参数暴露。陈旧版本检查由后端产生的版本和策略插件的观测状态驱动,而非要求模型通过 schema 复制版本令牌。
### `edit`
`edit` 通过替换字面文本来更新已有的 UTF-8 文本文件。
参数:
- `file_path: string`——必填。要编辑的路径,由 `ctx.fs` 解析。
- `old_string: string`——必填。要替换的字面文本。首次实现中空字符串无效。
- `new_string: string`——必填。字面替换文本;空字符串表示删除匹配内容。
- `replace_all?: boolean`——可选。默认为 false。为 false 时,`old_string` 必须恰好匹配一处。
`edit` 要求同一执行上下文先前对该文件有过一次观测(任何窗口化的 read 都算——授权基于版本新鲜度,而非全文查看要求),或该上下文先前对该文件做过 write/edit。`dsh-fs-policy` 策略插件推导所有者并将记录的版本作为 stale guard 提供;提供方的 mutation lock 负责执行。
首次实现拒绝 Codex 风格的 patch 语法和多模式 edit API。它使用一种严格的字面替换模式使面向模型的契约保持简单并让后端掌控精确匹配、重复匹配、行尾和陈旧版本的语义。
## 结果形状
首次实现曾将 `ContentBlock[]` 格式化逻辑放在 `execute` 中。[规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)如今将 `ctx.fs` 的结果事实保留为工具经校验的值,并通过 `output.render` 派生相同的模型文本;文件状态的记录/刷新仍归 `ctx.fs` 所有。
默认原生投影:
| 工具 | `tool-fs` 使用的结构化 `ctx.fs` 结果 | 默认模型投影 |
|---|---|---|
| `read` | 返回的行、返回行数、总行数、目标显示路径、文件版本、部分视图标记 | 带行号的文本及分页页脚 |
| `write` | 创建/更新操作、目标显示路径、新文件版本 | 简洁的创建/更新成功文本 |
| `edit` | 替换次数、全量替换标记、目标显示路径、新文件版本 | 简洁的编辑成功文本 |
结构化结果不会重复模型参数(如 `file_path``old_string``content`),除非后端已将其解析为新信息(如 `displayPath``targetKey` 或新版本)。面向 token 的截断属于模型投影的职责,而非后端规范结果的一部分。
## 延后事项
以下内容被明确排除在首次文件系统 schema 实现之外:
- 面向模型的 `expected_hash``expected_version``create_only` 参数。
- 目录列表、glob、grep 和搜索工具。
- 二进制安全的读/写操作。
- PDF/图片/多模态 `read`
- 文件系统工具的 Code Mode 投影值。
- 规范的 edit diff 格式。
## 测试
schema 测试固定每个工具的必填/可选参数集、空 `old_string` 拒绝、`replace_all` 默认值、snake_case 字段名、描述文字中对观测策略的说明,以及根插件套件注册;集成测试通过 `ctx.tools.execute()` 对真实的 `dsh-fs-local` 提供方执行全部三个工具,并验证模型参数被正确转换为预期的 `ctx.fs` 调用和 `fs/*` 分发。
## 曾考虑的替代方案
- **Codex 风格的 patch 语法或多模式 edit API**:否决。一种严格的字面替换模式使面向模型的契约保持简单,并让后端掌控精确匹配、重复匹配、行尾和陈旧版本的语义。
- **camelCase 参数名OpenCode 风格)**snake_case 与 Claude Code 及现有 harness 工具 schema 示例一致,且命名一旦发布即成为公开接口。
- **面向模型的 `expected_hash` / `expected_version` / `create_only` 参数**:否决。陈旧检查由后端产生的版本和策略插件的观测状态驱动,从不依赖模型复制的脆弱令牌。
## 后果
**首版 schema 有意小于 Claude Code 的。** 去掉 PDF pages、多模态 read、丰富的 grep/list flag 和 expected hash 字段使实现保持聚焦,但用户可能很快就会提出这些需求。它们将以独立 Agent Note 或聚焦的后续工作形式到来,而非对初始 schema 的重载。
**v1 中没有显式的面向模型的 stale guard。** schema 不要求模型提供 expected hash/version。这是有意为之陈旧检查来自后端产生的版本和 `dsh-fs-policy` 插件的观测状态,而非模型复制的脆弱令牌。文件系统安全失败通过 `dsh-fs` 拥有的结构化 `FsError` 代码浮现,而非模型提供的版本字段。
**命名成为公开接口。** 一旦发布,将 `file_path` 改为 `filePath``old_string` 改为 `oldString` 会搅动提示词、示例和下游客户端。本 Agent Note 预先选择 snake_case并将其视为稳定的面向模型的契约。

View File

@@ -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-18-acp-terminal-and-tool-rendering.md: 166e52f8e659a78b1347279c61956ca1001e4939
2026-06-18-acp-terminal-and-tool-rendering.zh.md: 1623c8f51f9a1e0aacf374d182ab1c74dfa407cc

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-18-acp-terminal-and-tool-rendering.zh.md)
## 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.

View File

@@ -0,0 +1,48 @@
# Agent Note: 富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片
Status: implemented
[English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文
## 问题
ACPAgent 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。将执行路由到编辑器会绕过所有这些机制并将执行分叉到两个后端。
研究两个参考 agent2026-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`,回退到终端卡片——仅展示,绝不改变实际执行内容)。

View File

@@ -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-18-compaction-capability-seam.md: a263b5e7d0245bd1279024a50e05b2f33edad521
2026-06-18-compaction-capability-seam.zh.md: df0cf9d9131978e608d47124ba0f0db0343ee12a

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-18-compaction-capability-seam.zh.md)
## Problem
A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact.

View File

@@ -0,0 +1,132 @@
# Agent Note: 压缩作为能力 seam抽象契约 + 基础后端)
Status: implemented
[English](2026-06-18-compaction-capability-seam.md) | 中文
## 问题
长时间运行的 agent智能体对话会无限增长。随着事件日志不断累积轮次派生出的消息历史最终逼近模型的上下文窗口模型随即截断响应`max-tokens`)或性能退化。**上下文压缩context compaction** 是对此的缓解手段:用一段简洁的摘要替换一批较早的历史,保持近期上下文完整。
[会话接口面](../architecture/2026-06-18-session-surface.md)正是为此而构建的基础设施:一份建立在事件日志之上的有序投影,带有专门设计的 `surfaceOp: { op: 'replace', start, end }` 操作,用于遮蔽一段条目并插入替换内容,`sourceEventSeqs` 记录溯源信息以便决策可确定性地回放。剩下的是那个*决定压缩什么、并产出摘要*的插件。
两股力量塑造了设计。第一,压缩策略与可复用的 token 测量独立变化:测量归 LLM 系列的 [`ctx.tokenMeter` 服务](../architecture/2026-07-15-replay-token-meter-service.md)所有,摘要生成则可以使用模型调用、模板或远程服务。第二,`SurfaceEventType` 封闭为五种事件类型(`user/message``assistant/message``tool/result``context/message``steering/message`);只有这些类型可以携带 `surfaceOp`。因此一个专用的 `compaction/*` 事件**不能**出现在 surface 上,编译器与 Session 始终启用的 append/seed 边界都会拒绝在其上附加 `surfaceOp`
## 决策
### 压缩是一个能力 seam接口与实现分离
遵循[能力 seam Agent Noteagent 决策记录)](../architecture/2026-06-13-capability-seams.md)压缩以独立包package发布使契约、算法和后续的消费方 surface 各自独立演进:
1. **接口**`@deepseek-ai/dsh-compact`:抽象 `CompactService`,拥有 `ctx.compact` 键、`CompactionResult` 词汇、`compact/*` 会话事件以及规范的检查点消息来源。它将 `compactIfNeeded()``compactRegion()` 声明为**抽象方法**——契约说明压缩*做什么*,而非*怎么做*。
2. **实现**`@deepseek-ai/dsh-compact-basic`:具体的 `BasicCompactService`,消费 `ctx.tokenMeter`,并拥有尾→头保留遍历、通过 `ctx.llm.stream()` 生成摘要、surface 替换、锁、步骤后压力处理和规范的上下文溢出恢复。`summarize()` 是其唯一的子类钩子;计价与回放仍归 meter 所有。
3. **无模型配套服务**`@deepseek-ai/dsh-compact-tool-result-prune`:一个具体的可选服务,在后端选择摘要范围之前,重写当前过大的 `tool/result` 节点。它不是第二种压缩实现,也不实现 `CompactService`
4. **消费方** — 推迟。一个 `/compact` 工具和斜杠命令将 `inject: ['compact']` 并调用契约;它们被有意排除在本 Agent Note 范围之外,以便 seam 先稳定下来。
### 契约依赖 `dsh-session` 和 `dsh-llm`——有意为之的偏离
能力 seam Agent Note 规定接口包「仅依赖 cordis」`dsh-bash` 成立,因为其词汇是自包含的)。压缩**无法**遵守这一点:它的动词作用于 agent 所有的 `Session``compactRegion(start, end, agent)`),其输出使用内容词汇(`CompactionResult.summary: ContentBlock[]`)。不引用 `Session`/`SessionEvent`(来自 `dsh-session`)和 `ContentBlock`(来自 `dsh-llm`),契约就无法表达。
这不是耦合异味,而是契约的领域所在。「仅 cordis」的指导原则一直是「接口仅依赖契约真正需要命名的东西绝不依赖实现」的简写。`dsh-session``dsh-llm` 本身是接口/词汇包,不是实现;`dsh-compact` 仍然不导入任何后端。seam 的真正不变式——*消费方和实现在抽象服务背后独立演进*——完好无损。
### 抽象 `compactIfNeeded` / `compactRegion`,算法在后端
早期草案将完整算法保留遍历、token 求和、文本提取)作为接口上的具体方法。这会将契约重新耦合到一种策略:想要不同保留策略或事件排序的后端必须与继承来的具体代码对抗。将两个核心方法都设为抽象,把所有*怎么做*的决策放在后端,并让接口保持为*做什么*的声明。token 测量根本不是压缩钩子;单例服务使多个消费方能够共享逐会话的回放折叠。
`compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactRegion(start, end, agent, signal?)``agent.session` 作为唯一会话身份,并为手动调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`适配器可以将此用途映射为对模型隐藏的传输元数据DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`
### 成功的持久步骤工作完成后运行自动压力检查
成功调用的压力检查不能在步骤前运行,因为最终的 `agent/request` 路由、提供方输出、工具结果、缓冲上下文与 steering 当时尚不存在。串行的 `agent/post-step(agent, turn, step, signal)` 会在这些事实持久化后、`step/end` 之前触发。`dsh-compact-basic` 通过 `ctx.tokenMeter` 测量规范的已记录请求,因此下一个请求无需推测性覆盖信封即可看到任何替换。压力达到条件后,可选的 `ctx.toolResultPrune` 重写在摘要范围选择前运行compact-basic 重新测量持久 surface如果修剪恢复到安全压力便跳过摘要生成。
规范的提供方上下文溢出走另一条路径。失败步骤先关闭,`agent/request-error` 接收原始请求错误与连续重试次数compact-basic 在强制执行一次有效且平衡的缩减前先修剪。仅当 `session.surface.replaceGeneration` 增加时,它才返回 retry这包括没有摘要范围时仅修剪取得的进展。随后循环开启新的编号步骤并从持久日志重建请求。没有替换、任何替换前的恢复失败、取消、耗尽的上限或无关错误都会保留原始提供方失败。如果修剪已经推进 generation而后续摘要工作失败恢复会从该持久的已修剪 surface 重试,除非取消或资源释放胜出。完整生命周期决策见[调用后恢复 Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)。
```
assistant/message → tool/result/context/steering
await serial agent/post-step ⟵ pressure compaction inside the successful step
step/end
provider overflow → step/end
await waterfall agent/request-error ⟵ forced compaction between attempts
retry → next numbered step/start ⟵ derives from the replacement surface
```
### 保留是轮次无关的;工具配对平衡是唯一的结构守卫
自动压缩在**每个成功的**步骤之后检查,而非每轮一次。这对失控轮次存活至关重要:工具密集型的 ReAct 轮次每步追加一个 `assistant/message` + 一个 `tool/result`,因此 surface 会在一轮之内增长。步骤后检查可以在后续步骤开启前压缩早期已关闭的工具对;如果请求率先越过限制,由提供方确认的溢出仍是兜底机制。
`compactIfNeeded` 保留估算大小达到解析后保留 token 预算的最小完整 surface 单元尾部,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到切割点满足工具配对平衡。平衡按 surface 顺序检查,而非日志序号,因为替换摘要在旧的 surface 位置拥有新的序号。`dsh-compact` 导出前后边缘辅助函数;只要 `replaceGeneration` 不变,其逐会话缓存就只折叠新增的 surface 尾部节点,面对仅日志增长时不读取事件,并在替换后重建当前成员关系与平衡。`compactRegion` 拒绝将工具调用与其结果拆分的边界。进行中的轮次不享受特殊保留。
因此失控轮次的压缩方式与其他历史完全相同:其早期*已关闭*步骤被摘要,近期步骤保持原样。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 `null`)并在该步骤关闭后重试。
**部分单单元溢出仍不在范围内。** 摘要范围选择无法拆分不可分割的单元。当可移除的文本型工具结果内容占据大部分空间,且修剪后的余量能够容纳时,可选修剪器可以修复一个已关闭的工具对。仅信封压力、粘贴的 `user/message` 等不可分割的超大非工具节点,以及不可修剪余量仍然过大的工具单元,依旧不属于压缩范围;限制这些单元是另一个关注点。
### 头部锚定:一个自动检查点,始终在头部
自动压缩始终从 surface 头部开始,将先前的检查点与新压缩的历史合并,因此只保留一个自动检查点。`shadowedRange` 因此是位置性的而非数值序号区间:一个较新的摘要序号可能占据较旧的 surface 位置。`shadowedSeqs` 记录权威的 surface 顺序。手动的中间范围压缩可能留下多个检查点。
### 近似收敛不变式
`resolveConfig` 提供可用默认值:阈值比例 `0.8`、保留尾部比例 `0.16`、空的摘要提供方/模型覆盖、`maxTokens: 8192``compactionRetries: 1``maxOverflowRetries: 1` 以及 `auto: true`。可选的精确提供方/模型策略会部分覆盖顶层默认值;压力根据拥有该路由的 LLM 适配器所报告容量缩放比例,而 `retainTokens` 可以替代按比例保留。保留量必须低于最终阈值。收敛仍然是动态的,因为提供方输出上限可能被隐藏或显式的推理 token 消耗,摘要大小也不可预测。如果压力仍高于阈值,`compactIfNeeded()` 会按配置的重试次数再次压缩头部检查点,但每次提交的摘要必须小于其遮蔽的内容。溢出不需要容量元数据,并会绕过阈值和保留尾部策略,执行一次最大且平衡的头部缩减,留下最新的不可分割单元。所有权划分由[已路由模型上下文与压缩策略 Agent Note](../architecture/2026-07-20-routed-model-context-and-compaction-policy.md)规定。
### Surface 替换:`compact/*` 事件仅存在于日志;一条 `user/message` 承载摘要
由于 `SurfaceEventType` 是封闭的,摘要不能搭载在 `compact/*` 事件上。后端改为追加一条**单独的 `user/message`**,带有 `source: COMPACT_CHECKPOINT_SOURCE``surfaceOp: { op: 'replace', start, end }`;其 `content` 是(带框架的)摘要,`sourceEventSeqs` 覆盖被遮蔽的条目*和*簿记事件。接口导出该来源和 `isCompactCheckpointSource()`,使消费方无需依赖后端包身份,即可识别持久化或克隆得到的检查点。`compact/*` 事件是纯日志记录(锁 + 溯源信息。surface 变更位于锁**内部**`compact/end` 是最后追加的事件:
```
compact/start → log-only. Acquires the lock.
[summarize older range via the backend]
compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count.
user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }.
THE surface mutation (framed summary).
deriveMessages() renders it as a user-role message.
compact/end → log-only. Releases the lock (carries `error` on a recoverable failure).
```
`deriveMessages()` 随后产出 `[summary_as_user_message, ...retained_entries]`。复用 `user/message` 是诚实的而非变通:摘要确实*是* user 角色的上下文。
### 检查点框架 + 增量合并(后端私有)
基础后端将摘要包装为已建立的检查点上下文,并标记以便下一轮增量合并。原始摘要保留在 `compact/summary` 上。框架是后端策略seam 承诺由一条替换 user 消息承载可能带框架的摘要,并使用规范的检查点来源。
### 通过日志记录的锁实现阻塞,加上崩溃/可恢复失败的分类
`compact/start … compact/end` 括号的存在理由,按当前实际承担的职责排序:
1. **可检测的崩溃孤儿 + 来源追溯**(首要)。摘要生成是一次慢速模型调用,持久化在 `compact/start` *之后*。摘要生成中途崩溃会留下一个没有匹配 `compact/end``compact/start`——一个可检测的孤儿。最后释放锁(而非最先)将崩溃窗口从*静默损坏*转变为可检测的孤儿。
2. **防止并发压缩。** 如果当前轮次持有未匹配的 `compact/start``compactRegion` 拒绝启动。(循环在任一 awaited 自动 seam 上都是单线程的因此这也是重入绊线——抛出「already in progress」表示真正的 bug。
该锁只排除另一项压缩,不排除无关的仅日志事实。基础后端会在 `compact/start` 之后对 token meter 的 surface 节点取快照,并在异步摘要后再次比较;任何 surface 变更都会使替换前的检查失败,而标题或其他仅日志追加不会使已选范围失效。
两种失败路径,均有文档记录:
- **崩溃**(循环在摘要生成中途死亡):悬空的 `compact/start`,无关闭事件。由于 `compact/*` 是**仅日志**事件,孤儿是**惰性的**,不会落地摘要替换。派生 surface 保持为 `compact/start` 时已经持久化的 surface如果修剪未产生替换就是完整历史如果已经修剪就是已修剪历史。通用轮次修复`interruptedTurnClosers`)用合成的 `turn/end` 关闭轮次;孤儿位于该 `turn/end` *之前*,因此轮次范围内的进行中检查永远看不到它,崩溃不会卡住未来的压缩。
- **可恢复**(摘要生成抛出异常但循环存活):后端追加设置了 **`error`** 字段的 `compact/end`,但不落地摘要替换。步骤后压力处理发出警告,并从最新的持久 surface 继续:如果尝试前没有替换,就是完整历史;如果修剪已经落地,就是已修剪 surface。溢出恢复只会在没有任何替换前委托先前修剪带来的 generation 进展允许从该持久 surface 重试,除非取消或资源释放胜出。
`compact/end` 保留其 `error?` 字段(与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件)。没有单独的 `compact/error` 事件。
**核心会话修复保持对压缩无感知——这是有意为之。** `interruptedTurnClosers` 从不被教导 `compact/*`。如果教导它,每个未来的 `xxx/start … xxx/end` 插件对都必须修补核心模块——这恰好是能力 seam 架构存在的意义所要避免的耦合。由于仅日志的孤儿是惰性的,不需要特殊修复:通用轮次修复加上未落地 surface 变更的惰性就足够了。
## 曾考虑的替代方案
- **完整算法作为接口的具体方法**——否决,因为它将契约重新耦合到一种保留策略。两个核心方法都是抽象的;可复用测量属于单独的 LLM 系列服务,`summarize()` 是 basic 唯一的钩子。
- **在 `agent/request` 或临时 `agent/pre-step` 输入上执行压缩**——否决,因为两者都无法证明最终的持久请求,而且都会将通用生命周期耦合到压缩专属的信封数据。步骤后回放与规范溢出恢复同时覆盖成功和被拒绝的调用。
- **`compact` 布尔值或无类型的请求元数据 map**——否决,因为多个辅助调用种类会变成互斥标志,而开放 map 会丢弃由编译器检查的词汇。一个类型化的 `purpose` 判别字段可以扩展其他调用种类,而无需再为 `GenerateOptions` 添加字段。
- **单独的 `compact/error` 事件**——否决:`compact/end` 保留 `error?` 字段,与 `tool/result` 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件。
- **教导核心轮次修复识别 `compact/*`**——否决:仅日志的孤儿是惰性的,为每个未来的 `xxx/start … xxx/end` 插件对修补核心模块恰好是能力 seam 架构存在的意义所要避免的耦合。
## 后果
- **包**`packages/compact/compact` 提供接口,`compact-basic` 提供后端,`compact-tool-result-prune` 提供可选的确定性重写。`packages/llm/token-meter` 独立拥有回放感知的测量。消费方层推迟。
- **自动 seam**`agent/post-step``@mode serial`)处理成功调用的压力,`agent/request-error``@mode waterfall`)处理失败步骤关闭后的最终请求失败。通用 `agent/pre-step` 保持为四参数检查点,不携带压缩专属的提示词/前缀 payload。
- **`SessionEventMap`** 通过可合并扩展的声明合并获得 `compact/start` / `compact/summary` / `compact/end``SurfaceEventType` **未被**触及。这些是会话事件,不是 cordis `Events`,因此事件分类门禁无需新增条目。
- **`dsh-compact`** 拥有 `COMPACT_CHECKPOINT_SOURCE``isCompactCheckpointSource(source)``toolPairingBalancedBefore(session, seq)``toolPairingBalancedAfter(session, seq)`。该标记用于跨后端实现识别替换摘要。带缓存的 surface 边缘检查会防止 `compactRegion``compactIfNeeded` 拆分工具调用/结果对,按 seq 校验当前成员关系,从每个切割点的一条平衡序列回答两侧边缘,并拒绝陈旧或缺失的 seq 与孤立结果。
- **`dsh-session`** 通过唯一的 surface 管理器校验位置替换、完整溯源信息和仅内容的单节点 `tool/result` 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用;已校验的替换仍是位于轮次内的重写。
- **接线**`examples/tui-agent/cordis.yml` 依次加载零配置的 `dsh-token-meter``dsh-compact-tool-result-prune``dsh-compact-basic`;服务级默认值使组合无需重复数值策略即可使用。
## 测试
- **单元测试:** 使用真实 Loader 和 invariant 插件覆盖完整单元保留、修剪配置与回放、富块顺序、元数据保留、收敛、`compact/end` 的两种结果、开放尾部拒绝、仅修剪与带摘要的溢出恢复、generation 证明、上限和原始错误保留。
- **循环测试:** 测试固定步骤后处理发生在持久工具结果之后、`step/end` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。
- **带密钥 e2e** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface并完成任务。
- **快照缺口:** 失控轮次压缩尚无法回放,因为摘要调用未记录 `assistant/chunk` 事件或 `sessionId`;交错摘要调用的回放仍是后续工作。

View File

@@ -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-subagent-capability-seam.md: 9c17a93751de209e5e4e5a0ca7d7b1d8e5656a47
2026-06-21-subagent-capability-seam.zh.md: 6294c84a8fa11e492316f4b69048aa5f477aa04f

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-21-subagent-capability-seam.zh.md)
> The full seam is shipped: the `dsh-subagent` interface and `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its Agent Note](2026-06-22-acp-subagent-backend.md)).
## Problem

View File

@@ -0,0 +1,73 @@
# Agent Note: Subagent 能力 seam
Status: implemented
[English](2026-06-21-subagent-capability-seam.md) | 中文
> 完整 seam 已交付:`dsh-subagent` 接口与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外后端 `dsh-subagent-acp`[其 Agent Note](2026-06-22-acp-subagent-backend.md))。
## 问题
harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent智能体将工作委派给另一个 agent。这一意图在 `Agent`/`AgentLoop` 接口中已有草案([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts)、[packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)):一个创建选项引用父 agentfork = 用父会话的事件日志初始化子会话spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering中途引导和事件订阅可以统一工作。本 Agent Note 实现了这个 seam上方横幅列出了已交付的内容。
决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent通过 ACPAgent Client Protocol。我们预见的传输方式
- **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本);
- **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例);
- 后续:**A2A**、**Codex app-server** 与 **Claude Code Agent SDK**——每种都与 ACP 后端相同的进程外形状:「启动子 agent、发送提示词、流式接收更新、取消」。
## 曾考虑的替代方案
### 为何不采用 bash seam 的形状
bash seam[能力 seam](../architecture/2026-06-13-capability-seams.md))在每个上下文中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM大语言模型适配器注册表**`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口 / 实现 / 消费方);只是「一个 vs. 多个实现」这个维度不同。
## 决策
### 由三个包构成的 seam
新建包package`packages/subagent/`
| 包 | 角色 |
|---|---|
| `@deepseek-ai/dsh-subagent` | 接口:`SubagentService``ctx.subagents`)、`SubagentProvider``SubagentRun`、请求/结果/能力词汇、`subagent/*` 事件 |
| `@deepseek-ai/dsh-subagent-spawn` | 实现:通过 `ctx.agents.create` 创建全新的进程内子 agent |
| `@deepseek-ai/dsh-subagent-fork` | 实现:用父 agent 日志快照初始化的进程内子 agent |
| `@deepseek-ai/dsh-subagent-acp` | 实现:作为 ACP 客户端驱动已配置的子进程 |
| `@deepseek-ai/dsh-tool-subagent` | 消费方:基于 `ctx.subagents` 的面向模型的 `subagent` 工具 |
### 原语:异步 `start → SubagentRun`
提供方暴露 `start(request) → Promise<SubagentRun>`。完成时发布一个就绪的子 agent 并将其运行句柄转交给调用方。一个信号覆盖就绪前后的取消;`dispose()`(资源释放)取消剩余工作并等待完全停稳。启动失败时清理部分资源,不发出生命周期事件。`start` 与传输方式无关;`spawn` 仅指代全新的进程内后端。
### 两类可选能力,两种发现方式
- **启动时功能**`outputSchema``depthLimit``toolFilter``persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则**大声拒绝**`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。
- **运行时功能**(通过 `sendMessage` 进行 steering、通过 `resume` 进行后续对话)是 `SubagentRun` 上的**可选方法**。方法的存在本身即为能力TypeScript 类型收窄即为发现机制:消费方不经收窄就无法调用不存在的方法,因此不存在静默降级路径,也不需要额外的 flags 对象来保持同步。
### Fork 与 fresh 是独立后端,而非一个 flag
全新子 agent 与 fork 子 agent 是独立的提供方,而非请求中的一个 flag。`dsh-subagent-spawn` 启动隔离的子 agent`dsh-subagent-fork` 用一个平衡前缀初始化子 agent该前缀仅包含已完成的父轮次。进行中的轮次被排除因为其 subagent 调用尚无结果,无法构成有效的回放历史。
### 子 agent 隔离与父日志
每个 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。父日志仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出)——子 agent 的内部步骤和工具调用留在子 agent 自己的会话中绝不注入父日志。这是唯一在所有传输方式下行为一致的设计ACP 子 agent 的内部事件在物理上无法注入我们的父日志,因此让进程内行为保持一致,使 seam 真正与传输方式无关。
### 同步收集(首版)
`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在 `finally` 中 dispose 该 run。非完成态的结果变为错误结果而非成功的部分输出。这个前台消费方不使用 run 的可选 steering 方法。
### 提供方选择是配置,不面向模型
`dsh-tool-subagent` 绑定到恰好一个提供方名称(`Config.provider`);模型只看到 `{ description, prompt }`。若要暴露多种传输方式,请多次加载该工具插件,每次绑定不同的提供方和不同的 `toolName`(工具注册表拒绝重名)。*服务*持有多提供方注册表;*工具*选择其中一个——本版 schema 中没有提供方/type 参数。
## 测试
注册表与工具测试仅用包内脚本化提供方替换非确定性的子进程边界,同时运行真实的 `SubagentService`、生命周期、任务集成和面向模型的工具。提供方与消费方的 export 形状仍保留 Loader 回归覆盖,以防止[事后分析 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md) 中描述的失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。
## 后果
- **递归。** 如果不设限制,进程内子 agent 能看到委派工具并递归调用。进程内后端实现了可选的绝对深度限制和有作用域的实时全局 `toolFilter`ACP 声明这两项能力为关闭状态,并拒绝此类请求。[subagent 组合控制 Agent Note](2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责定义它们的确切语义和安全边界。
- **阻塞父轮次。** 前台收集在子 agent 的整个持续时间内保持父 agent 的步骤打开。后台委派使用共享的 `ctx.tasks` 运行时与通用 `task_*` 工具,与后台 bash 共用同一套收集机制subagent seam 本身仍不感知任务。
- **实时进度。** 本版仅暴露生命周期事件与最终结果;逐分片的子→父更新流推迟到后台重新设计时一并处理。
- **ACP 客户端接口。** 将 ACP 子 agent 的 `fs`/`terminal` 代理回父 agent共享工作区模式是后续工作首版不声明这两项能力子 agent 在自己的进程中自行服务。

View File

@@ -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-22-acp-subagent-backend.md: a45ce5e34873249969bbe4dabb87a89d10246b3d
2026-06-22-acp-subagent-backend.zh.md: 3b6a11efc1ae0427eb5ce3b30029b77b00d6801a

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-22-acp-subagent-backend.zh.md)
## Problem
The subagent seam ([the seam Agent Note](2026-06-21-subagent-capability-seam.md)) was built so multiple backends coexist by name on `ctx.subagents`. The in-process backends (`-spawn`/`-fork`) run a child as a second `Agent` on the SAME cordis context — cheap, but the child shares the parent's process, model client, and tools. The seam's whole point was to also support an OUT-OF-PROCESS child reached over a protocol, proving the abstraction generalizes across a process boundary. This Agent Note adds the first such backend: an Agent Client Protocol (ACP) client.

View File

@@ -0,0 +1,62 @@
# Agent Note: ACP subagent 后端(进程外委派)
Status: implemented
[English](2026-06-22-acp-subagent-backend.md) | 中文
## 问题
subagent seam[seam Agent Note](2026-06-21-subagent-capability-seam.md))的设计使多个后端可以按名称共存于 `ctx.subagents`。进程内后端(`-spawn`/`-fork`)将子 agent智能体作为第二个 `Agent` 运行在同一个 Cordis 上下文上:开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义在于同时支持通过协议到达的进程外子 agent以证明该抽象能跨越进程边界泛化。本 Agent Note 添加第一个此类后端:一个 ACPAgent Client Protocol客户端。
## 决策
`@deepseek-ai/dsh-subagent-acp` 注册一个 `SubagentProvider`,将每个子 agent 运行在一个派生的子进程中,并以 ACP *客户端*身份驱动它。它是现有服务端桥接 `@deepseek-ai/dsh-acp`ACP *agent*)的方向反转孪生体:桥接应答 `initialize`/`newSession`/`prompt`;本后端调用它们并实现 `Client` 回调(`sessionUpdate``requestPermission`)。将配置的 spawn 命令指向 `acp-agent` 示例,即可让 harness 与自身进程通信。
### 每次运行启动全新进程
每次 `start` 都 spawn 一个新的子进程,运行恰好一个 ACP 会话(`initialize``newSession``prompt``dispose` 杀死子进程并等待其退出。这是最简单的生命周期,与进程内「每次运行一个子 agent」的形态一致。
### 最小化客户端桩
客户端不声明任何可选能力(无 `fs`、无 `terminal`):子 agent 在自己的进程中自行处理文件/终端访问。`session/update` 通知被消费:后端将 `agent_message_chunk` 文本累积为结果输出,在本阶段忽略其余内容(思考、工具调用卡片),仅暴露子 agent 的最终回答。`session/request_permission` 由配置的策略自动应答(`reject` 拒绝所有提示,`allow` 通过第一个允许形态的选项批准)——本阶段不向人类暴露任何权限提示。将 `fs`/`terminal` 代理回父进程(共享工作区模式)仍为后续工作,如 seam Agent Note 所述。
### 无启动时能力
提供方的 `capabilities` 全部为 `false`。进程外子 agent 无法遵守父 agent 的 `maxDepth`(它无权访问 `parent.options.subagentDepth`)或 `toolFilter`(它拥有自己的工具注册表),本阶段也未实现 `outputSchema`。如果请求需要其中任何一项,服务在 `start` 运行前即拒绝。后端仅注入 `subagents`(而非 `ctx.agents`);它从 `request.parent` 读取的唯一内容是会话 header 的 cwd见下方工作区解析——对话上下文、深度和工具状态都不会跨越进程边界。
### 工作区 cwd 解析
子进程工作目录来自显式解析,绝不使用 harness 进程的 cwd若已配置部署 `cwd` 覆盖,则相对于启动目录将其转为绝对路径并在加载时验证;否则使用父会话 header 的 cwd 并在启动时验证;如果两者都不存在,则在生成任何进程前大声拒绝。一个 ACP 服务端进程会服务来自多个工作区的会话,因此 `process.cwd()` 不能代替会话工作区——旧的隐式回退会让子进程在服务端启动目录中运行。候选路径必须是 harness 可以进入的绝对目录(要求 `X_OK`;仅 `statSync().isDirectory()` 会接受 mode-600 的目录,而 spawn 会因 EACCES 失败);解析出的同一路径同时用作子进程 cwd 与 ACP `session/new` 工作区。
### StopReason 映射
ACP `StopReason` → harness `SubagentStopReason``end_turn``completed``max_tokens``max-tokens``refusal``refusal``cancelled``aborted``max_turn_requests``error`(无对等语义,任务未完成)、未知→`error`。spawn/传输/RPC 失败解析为 `error`(如果已请求取消则为 `aborted`);按 seam 契约,`result` 在子 agent 级别失败时从不 reject。
### 安全:清洗子进程环境
子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(`/KEY|SECRET|TOKEN/i`)默认不转发——父 harness 自身的密钥不得隐式泄露到派生进程中(与 bash 执行器采用的策略相同)。子 agent 自己的凭证(它需要模型密钥)通过 `config.env` 显式提供,在清洗之后叠加,因此有意传入的 `DEEPSEEK_API_KEY` 得以保留,而偶然存在的 `AWS_SECRET_ACCESS_KEY` 则不会。子进程的 stderr 继承到父进程的 stderr诊断信息自然浮现spawn 级别的 `error` 事件(如命令不存在时的 ENOENT被捕获并与 ACP 驱动竞速,因此错误命令解析为 `error` 而非以未处理错误崩溃父进程。
## 测试
- **无需密钥的单元/集成测试:** 一个脚本化的 ACP 子进程通过真实 stdio 测试提示词/输出流、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、会话前竞态和管道断裂场景、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。
- **无需密钥的 Loader 组合测试:** 仅用于测试的 cordis.yml 通过真实 Loader 启动 stdio 应用,并省略后端的 `cwd`;脚本化模型委派一次,脚本化子进程则证明它在父会话工作区中运行,且 ACP 也对外公布了该工作区,从而端到端覆盖 cwd 继承分支。
- **需要密钥的 e2e 测试:** 后端 spawn 真实的 ACP 示例;其模型回答 `PONG`,写入 `proof.txt`,父进程验证该文件。
- **快照缺口:** 每个 ACP 子 agent 是独立进程,拥有自己的回放会话,不同于进程内的按会话回放。确定性 mock 服务器覆盖率已具备;`TODO(acp-subagent-replay)` 跟踪父进程对回放中子 agent 的回放支持。
## 曾考虑的替代方案
### 为何继续使用 SDK 0.25.1
后端只需要 `ClientSideConnection``ndJsonStream``PROTOCOL_VERSION` 和客户端协议类型0.25.1 全部支持。0.28 的 fluent API 需要在 ACP 层同时迁移客户端和服务端连接类,却不会改善本后端,因此升级作为独立变更保留。
### 为何不使用持久子进程?
持久进程池(跨运行复用热子进程)是一项性能优化,推迟到后续工作。它增加了会话生命周期和崩溃恢复的复杂度,本阶段不需要;每次 `start` spawn 全新子进程与进程内「每次运行一个子 agent」的形态一致。
## 后果
每次运行都要付出一个全新子进程的代价spawn + `initialize` + `newSession`)。父进程仅暴露子 agent 的最终回答:`session/update` 中的思考和工具调用卡片被消费后丢弃,权限提示从不到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥需通过 `config.env` 显式提供。
## 后续提供方
同样的进程外启动/提示词/流式输出/取消形态可泛化到 seam Agent Note 中列出的其他传输方式——A2A、Codex app-server 和 Claude Code Agent SDK——每个都是按名称注册的兄弟提供方。ACP 后端证明了 seam 支持跨进程边界;其余在机制上类似。

View File

@@ -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-25-ask-user-question.md: 9eab7aeb17774961dfc40224accdcfcbd5eb9d5f
2026-06-25-ask-user-question.zh.md: eb166acfef7ed8f7ab4eb3018bf161a9ecc3549e

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-25-ask-user-question.zh.md)
## Problem
The agent sometimes cannot proceed safely from model inference alone: it needs the human to choose a path, confirm a risky/default action, or provide missing information. Before this change, the only way to get that answer was for the model to ask in assistant text and then stop, which broke the normal tool-call loop: the agent had no structured way to pause, no option metadata for UIs, no abort/error taxonomy, and no way for non-stdio front doors to present the question consistently.

View File

@@ -0,0 +1,55 @@
# Agent Note: ask-user 提问能力
Status: implemented
[English](2026-06-25-ask-user-question.md) | 中文
## 问题
agent智能体有时仅凭模型推理inference无法安全地继续执行它需要人类选择路径、确认有风险的或默认的操作或者提供缺失的信息。在此变更之前获取答案的唯一方式是模型在 assistant 文本中提问然后停止这打断了正常的工具调用循环agent 没有结构化的暂停方式,没有供 UI 使用的选项元数据,没有中止/错误分类体系,也没有让非 stdio 前端一致地呈现问题的途径。
这是一个面向用户的能力但它也跨越了包package边界。面向模型的工具需要一套提供方无关的请求词汇每个 UI 界面需要决定如何展示和收集答案agent loop智能体循环应保持不变因为工具调用本身已具备正确的异步形状。
## 决策
引入 `dsh-user-interaction` 作为 `ctx.userInteraction` 的提供方无关接口包,与面向模型的消费方 `dsh-tool-ask-user` 一同放在 `packages/ui` 下。这一分组是有意为之的:向人类提问是一种由 UI 支撑的产品功能不属于无提供方的核心主干。seam 仍然拥有稳定的请求/应答/错误词汇,而 UI 产品界面提供收集答案的具体提供方。该工具注册 `ask_user_question`,转发 `{ questions, agent, signal }`,并将提供方计算出的结构化答案作为工具结果返回。
面向模型的请求词汇有意与产品调研 schema 对齐:`ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })``id` 按问题提供并在结果中回传,使批量请求无需依赖问题文本即可路由。`label` 既是面向用户的显示文本,也是返回给模型的选中值;没有单独的 `value`,没有 `recommended`,没有 `allow_custom`,也没有 `desc` 别名。
提供方返回 `{ answers: [{ id, selected, custom? }] }``selected` 始终是选中选项 label 的数组,因此单选和 `multi_select` 的答案共享同一种结果形状。`custom` 承载自由文本的「其他」答案;无选项的问题直接收集 `custom`。当 `custom` 存在时,它覆盖任何已选择的选项,`selected` 为空。支持部分完成的提供方使用现有的 `{ id, selected: [] }` 形状表示某项被有意跳过,在不扩展工具结果词汇的前提下保留其他答案。
`UserInteractionError` 继承 `HarnessError`,因此 `NO_PROVIDER``ASK_ABORTED`、ACPAgent Client Protocol取消或会话路由缺失等失败会以机器可路由的 `{ name, code }` 工具错误形式通过 `ctx.tools.execute()` 传出。这与结构化错误分类体系一致,使模型或包装插件能够区分「用户取消」与一般的抛出异常。
## UI 映射
`dsh web` 挂载 `dsh-client-ui-question`:其 host 侧使 Web 产品选择性加载面向模型的工具,浏览器侧则在 conversation 拥有的具名输入区 slot 中注册 `question` 项。`createApiProxy` 使用以 host 生成的 rpcId 为键的进程内 pending 表实现 Web 提供方。它先注册等待项,再广播 `question/requested`;每次 mux 重开时以相同 id 重放;在受理前校验会话和完整答案批次;并在回答、取消、中止或资源释放后广播 `question/resolved`。受理会同步删除该条目,因此首个有效响应胜出,重复或迟到的响应返回 `not-pending`
Web 输入区一次显示一个问题,同时在会话对象层保留每个请求。它支持单选、多选、无选项问题或显式自定义答案、描述文本与可视化推荐标记,但不会自动选中推荐项。选择单选项后会立即进入下一项;当所有项都已回答或显式跳过时,按 Enter 提交IME 组字期间按 Enter 只会确认输入候选项。页脚只跳过当前项并保留先前的草稿;关闭控件以 `ASK_CANCELLED` 拒绝整个工具调用。常规输入区只有在 host 的 resolved 帧移除待处理项后才会恢复。
`dsh-tui` 将每个问题渲染为键盘叠层,展示选项描述,支持单选、多选和自由格式自定义答案,并在中止、提供方 dispose资源释放或终端关闭时拒绝待处理的问题。批量请求和并发请求都会排队确保同一时刻只有一个叠层占用键盘焦点。
`dsh-acp` 为 ACP 会话提供同一 seam。它通过 `ownedRecord` 解析调用方 `Agent`,要求位于 `agent.session.id` 的正向会话 map 记录拥有该精确 agent 对象,并为每个问题调用 ACP `unstable_createElicitation`(附带会话范围的表单)。单选选项变为 `choice` 字符串枚举;`multi_select` 选项变为 `choice` 数组枚举;无选项的问题使用必填的 `custom` 文本字段。如果客户端同时返回 `choice` 和非空 `custom`,以 custom 答案为准。ACP `decline`/`cancel`、缺失答案、缺失会话以及客户端不支持 elicitation都会转为结构化的 `UserInteractionError`
ACP 映射有意使用 elicitation 而非 `session/request_permission``request_permission` 仍保留给独立的权限门禁:它是围绕工具执行的 yes/no 或策略式授权协议。`ask_user_question` 是一个通用的信息收集工具,支持可选的自由格式答案,因此 ACP 表单 elicitation 是更贴合的协议。bridge 的会话路由与未来的权限门禁共享,但用户意图不同。
## 曾考虑的替代方案
**Assistant 文本后跟一个停止的轮次。** 模型可以在纯 assistant 文本中向用户提问然后停止。这会丢失结构化选项元数据UI 没有提供方无关的方式来渲染选择,且下一条人类回答只能作为新的 user 提示词到达,而非作为需要答案的那次操作的结果。
**核心拥有的 ask-user 包。** 最初实现将 seam 和面向模型的工具分别放在 `packages/core``packages/ui`,但两者描述的是同一个由 UI 支撑的人机交互功能。seam 仍然是提供方无关的,但它不是像会话、工具或 agent 注册表那样的无提供方核心基础设施。将 `dsh-user-interaction``dsh-tool-ask-user` 一起放在 `packages/ui` 下,使包的划分与产品边界一致:应用和 bridge 提供人类答案的提供方stdio 应用选择性加载面向模型的工具。
**ACP `session/request_permission`。** 权限请求是围绕工具执行的授权;`ask_user_question` 是带可选自由格式答案的信息收集。将权限用于通用提问会混淆两个不同的产品概念,并使未来的权限门禁更难推理。
**循环级别的暂停原语。** agent loop 已经知道如何等待工具调用并从工具结果恢复。添加新的循环特殊分支会重复这一异步形状,并迫使每个循环实现都了解一个 UI 关注点。
## 后果
ACP elicitation 目前在 SDK 中标记为 unstable。回退仍然是结构化的如果客户端未实现它工具返回 `ASK_FAILED` 而非挂起。后续 ACP 稳定化可能重命名或重塑该方法;该迁移应限制在 `dsh-acp` 内部,因为核心 `ctx.userInteraction` 词汇是提供方无关的。
该功能赋予模型一个强大的暂停原语,因此提示词引导很重要。工具描述告诉模型:提问要简洁,尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。
`dsh-user-interaction``dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或提供方。`dsh-tui-demo` 选择性加载 seam、TUI 提供方和面向模型的工具。`dsh web` 在 host 运行时启动 seam/提供方,并通过选定的 Web question 插件暴露该工具。`acp-agent` 默认只保留 `userInteraction` seam/提供方ACP elicitation 支持仍取决于客户端,因此 ACP 叶节点必须在其客户端能完成 elicitation 请求后才有意加载面向模型的工具。
## 测试
单元覆盖率固定了以下场景:提供方注册/释放、重复提供方拒绝、提供方就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 传出的结构化工具错误、批量答案、多选答案、自定义答案、显式按项跳过,以及模型 schema包括移除 `value``recommended``allow_custom``desc`。TUI 测试覆盖选项描述、排队请求、关闭/中止清理、无选项自由格式输入、无效选择、重复多选和批量问题流。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。Web 测试固定稳定 id 重放、响应校验、首个响应胜出的结算、重复和迟到响应、整个请求的取消与拥有方中止的区别、单选后前进、IME 安全的 Enter 提交、按项跳过保留、输入区接管、结构化批量提交,以及常规输入区的恢复。

View File

@@ -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-29-todo-write-tool.md: bf5fceaafd475224914212beb460fdbc42e3dd68
2026-06-29-todo-write-tool.zh.md: 3afc03a393284e2a9c2d6e234bc95f0faebbfb48

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-29-todo-write-tool.zh.md)
## Problem
The harness gives the model bash and subagent tools but no way to record a structured task list. A todo list serves two co-equal purposes: it steers the model to plan multi-step work and keep the active task unambiguous (at most one active, exactly one while work remains), and it gives the human a live progress checklist. The ACP protocol has a native `plan` sessionUpdate that editors (Zed) already render, but the bridge never emitted one. Every reference coding agent surveyed (claude-code, opencode, codex, oh-my-pi, pi) ships some form of this; the harness had nothing.

View File

@@ -0,0 +1,64 @@
# Agent Note: `todo_write` 工具——将模型任务列表作为事件溯源的会话状态
Status: implemented
[English](2026-06-29-todo-write-tool.md) | 中文
## 问题
harness 为模型提供了 bash 和 subagent 工具却没有办法记录结构化的任务列表。todo 列表有两个同等重要的用途引导模型规划多步骤工作并保持当前活跃任务明确最多一个活跃有剩余工作时恰好一个同时为人类提供实时进度清单。ACPAgent Client Protocol协议原生支持 `plan` sessionUpdate编辑器Zed已能渲染它但 bridge 从未发出过。调研的所有参考编码 agent智能体claude-code、opencode、codex、oh-my-pi、pi都提供了某种形式的此功能本 harness 此前没有。
## 决策
新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。stdio UI 和 ACP bridge 均从现有的 `session/event` 渲染ACP bridge 将列表映射为 `plan` sessionUpdate。
### 整列表替换,三态 status
模型每次调用发送完整列表;新列表替换旧列表(回放时 last-write-wins。这是 claude-code V1、opencode 和 codex `update_plan` 共同采用的形状,也是模型训练最多的形状——没有逐项 id没有 delta 协议。`status` 恰好是 `pending | in_progress | completed`:与 codex `update_plan` 相同的三元组,且关键的是**与 ACP `PlanEntryStatus` 完全一致**bridge 因此可以 1:1 映射,无需有损转换。
### 状态在会话日志上,而非服务
列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM大语言模型历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和 `session/load` 重建:重新打开的会话从最后一条 `todo/write` 重新推导当前列表ACP bridge 在加载时重新发出 `plan`,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。
### 不是 surface 事件
`todo/write` 被有意排除在 `SurfaceEventType` 之外。surface 是产出 LLM 消息历史(`deriveMessages()`的投影todo write 不产生对话消息。因此它不携带 `surfaceOp`,不加入有序 surface不进入 `deriveMessages()`——它是持久、可回放的 *UI* 状态与对话并行传输但不属于对话的一部分。dev-mode 不变式仍要求它位于一个打开的轮次内,而它始终如此:它在工具调用的步骤中途追加。)
### Priority 仅在 ACP 边界合成
ACP 的 `PlanEntry` 要求 `content` + `priority` + `status`,但 `TodoItem` 没有 priority——模型从不推理它。与其在 schema 中增加一个模型每次都必须提供的字段bridge 在构建 `plan` 时为每条条目合成常量 `priority: 'medium'`。Priority 是 ACP 协议格式wire format的要求不是 harness 概念,因此它恰好存在于需要它的边界上。
### 相比 claude-code V1 舍弃的字段:`activeForm`、id、priority
claude-code V1 的条目是 `{ content, status, activeForm }`后来V2增加了 id、依赖和所有权——但仅为支持 agent *集群*(磁盘持久、锁保护、逐项变更)。本工具将条目保持在最小集:`{ content, status }`。不要 `activeForm`现在进行时标签——UI 直接展示 `content`;不要 id——整列表替换不需要稳定标识不要 priority——见上文。每舍弃一个字段模型每次调用就少产出一项。
### 单一所有者——无集群机制YAGNI
每个列表属于调用它的 agent 会话,非 agent 调用被拒绝。没有共享作用域、resolver 或 delta 协议。跨 agent 列表需要逐项日志 delta 和显式作用域选择,因此留作未来独立设计。
### 校验:低成本的中间路线
schema 强制 type/required/enum。在此之上`execute` 拒绝为空或重复的 `content`,以及超过一个 `in_progress` 任务。claude-code 将单一 in_progress 交给提示词约束oh-my-pi 在代码中强制。我们取中间路线:强制执行使计划*连贯*的低成本不变式(无空任务、无重复、最多一个活跃),但将排序和保持列表最新的纪律通过工具描述交给模型。被拒绝的写入返回 `isError` 结果,使模型自行修正。
## 为何没有 cordis-catalog 条目 / 没有 `@mode`
`todo/write``SessionEventMap` 的成员,不是一等的 cordis `interface Events` 事件。catalog 生成器(`scripts/gen-cordis-catalog.ts`)扫描 `interface Events` 声明;`SessionEventMap` 变体搭载现有的 `session/event` emit不产生新的 catalog 行。因此它不携带 `@mode` 标签(生成器仅对 `interface Events` 成员要求该标签)——添加一个毫无意义。
## 测试
四个层级,预先设计:
- **单元测试**——会话事件append/snapshot-clone/last-write-wins/not-on-surface工具schema 形状、通过真实 `ctx.tools.execute` 的参数校验、值校验、事件追加与替换、非 agent 拒绝、`presentCall`、HMR热模块替换安全性ACP `todosToPlan` 映射stdio 渲染分支。
- **真实 Loader 路径**——插件通过 `Loader.unwrapExports` 运行,断言命名空间导出形状存活(它有 `inject`,因此一个意外的 default 导出会在加载时崩溃——postmortem/0001
- **全循环集成**——一个脚本化的 mock 模型通过真实 agent loop智能体循环调用 `todo_write``todo/write` 事件落地,第二次调用替换它。
- **`session/load` 回放**——持久化的 `todo/write` 在新的 ACP bridge 加载会话时重新发出 `plan` 更新。
- **带密钥 e2e + 快照**——真实提示词诱导一次 `todo_write`;快照预期输出获得 `plan` 通知和日志事件。
## 曾考虑的替代方案
- **内存中的 `ctx.todos` 服务**——需要重新发明日志免费提供的持久性、回放和 `session/load` 重建。
- **逐项 delta 协议**——仅在共享多所有者列表时需要,超出当前范围;整列表替换更简单,且与参考实现一致。
- **工具放在 `core/` 中**——`todo_write` 是注册在 `ctx.tools` 上的扩展工具,不属于主干;它像其他工具族一样位于自己的 `packages/todo/` 分组中。
## 后果
todo 列表是持久、可回放的会话状态:持久化的 `todo/write``session/load` 时重新发出编辑器的 `plan` 更新日志而非插件内存是唯一真源。整列表替换意味着每次更新一次工具调用last-write-wins没有需要协调的 delta 协议。事件不进入 surface因此 todo 更新永远不会扰动推导出的模型历史——模型只看到自己的工具调用和结果。

View File

@@ -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-30-hook-bridges.md: b6b0894e5551563187b1631e8c641e326fa166b0
2026-06-30-hook-bridges.zh.md: 5f3adef958738d9d23c159c388be35b89f321828

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-30-hook-bridges.zh.md)
## Problem
The harness's extension surface is its typed interception seams ([the interception-seams Agent Note](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/prompt-submit`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`, `subagent/start`, `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This Agent Note introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md)).

View File

@@ -0,0 +1,70 @@
# Agent Note: dsh-hooks-claude + dsh-hooks-codex —— Claude Code / Codex 钩子桥接插件
Status: implemented
[English](2026-06-30-hook-bridges.md) | 中文
## 问题
harness 的扩展面是其类型化的拦截 seam见[拦截 seam Agent Note](2026-06-30-interception-seams.md)):所谓「原生钩子」不过是一个普通的 Cordis 插件,订阅 `agent/session-start``agent/prompt-submit``tools/pre-execute``tools/post-execute``agent/turn-continuation``subagent/start``subagent/end`。但用户带着**既有的** Claude CodeCC和 Codex 钩子配置到来,一个 `hooks.json`(或 settings 文件中的 `hooks` 键)里满是 shell 命令钩子,并希望它们原样运行。本 Agent Note 引入两个**桥接插件**,将外部 shell 钩子协议翻译到类型化 seam 上构建于共享的协议格式wire format库之上见 [hook-protocol-lib Agent Note](2026-06-30-hook-protocol-lib.md))。
贯穿整个设计的定位:**桥接是兼容性适配器,不是高级工具。** 桥接能做的事(阻止工具、注入上下文、强制继续、观察 subagent原生 Cordis 插件都能做得更强——类型化返回值、完整 `ctx`、无序列化边界。桥接存在的理由是运行外部 CC/Codex 命令钩子中被明确支持的子集。这使每个桥接保持精简:解析配置、选择匹配模式、构建每事件的 payload、调用共享库的 `runHook` + `mergeHookOutputs`,再将中性结果映射为 seam Decision。各包的 README 维护着当前不支持的事件和部分字段的完整清单,以官方协议为参照。
## 决策
`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见[事后复盘 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`
- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart``UserPromptSubmit``PreToolUse``PostToolUse``Stop``SubagentStart``SubagentStop`。拥有 CC 形态的每事件 stdin payload基础字段 `session_id`/`transcript_path`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。`transcript_path` 是持久化定位器结果或 `''`stdin 带有**尾部换行**。
- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse``PostToolUse``SessionStart``UserPromptSubmit``Stop`。使用始终为正则的匹配模式、Codex 形态的 snake_case payload`turn_id`/`model`/`permission_mode` 额外字段),写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`
### Outcome → Decision 映射
每个桥接将共享库返回的中性 `MergedHookOutcome` 映射到 seam 的类型化 Decision
| Seam | CC | Codex |
|---|---|---|
| `agent/session-start`emit | additionalContext → `agent.inject()` | 纯 stdout 输出 → additionalContext → `agent.inject()` |
| `agent/prompt-submit` | `deny``block`仅上下文→delegate+fold | `block``block`仅上下文→delegate+fold |
| `tools/pre-execute` | `deny``deny``ask``ask` | `block``deny`(无 allow/ask |
| `tools/post-execute` | `deny``block`+feedback仅上下文→delegate+fold | 同上 |
| `agent/turn-continuation` | 阻塞的 Stop → `continue`reason = 下一步 steering中途引导 | 同上 |
| `subagent/start`emit | additionalContext → 注入到存活的进程内 subagent远程 subagent 无本地注入目标 | 本桥接不支持 |
| `subagent/end`emit | 仅观察 | 本桥接不支持 |
CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决策:`dsh-tools` 通过可选的[审批 seam](2026-07-06-approval-seam.md) 来解析它。组合式 ACP 应答器向拥有该会话的编辑器会话发起提示,`allowed-once` 后继续执行;如果没有 ApprovalService 或应答器,调用以 `deny` 安全关闭。
### 上下文来源始终是插件(误标签防护)
`agent.inject()` 在缺少 `MessageSource` 时默认为 `{ kind: 'user' }`,因此每个桥接的 `inject()``HookContext` 都传入 `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`。单元测试覆盖率固定验证结果中的 `context/message.source` 为插件而非用户。
### 添加上下文不是否决——先 delegate再 prepend
仅附加 `additionalContext`(没有 block/deny的钩子并不是桥接可以独自返回的决策在 waterfall 监听器中不调用 `next()` 就返回 `allow`/`accept`,会短路其后的每个 `agent/prompt-submit` / `tools/post-execute` 监听器,使注册在桥接之后的策略/沙箱插件看不到该提示词。因此,每个桥接都会先通过 `next()` 委托,再将自身上下文加入下游决策。两个 seam 都携带有序的 `additionalContexts` 数组因此桥接会在保留所有下游来源、信封和元数据字段的同时前置加入其独立来源的条目下游提示词阻止仍会丢弃所有上下文因为提示词从未到达模型而工具后阻止语义可以显式保留上下文。Code Mode 会通过外层 `run_code` 结果转送同一数组。只有钩子本身真正返回 `deny`/`block` 才会短路。测试断言:上下文钩子允许后,较晚的监听器仍能阻止提示词,且保留的提示词和工具后上下文仍彼此分离。
### CLAUDE_PROJECT_DIR 默认为会话工作区
Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 `$CLAUDE_PROJECT_DIR` 来构造项目相对路径。显式的 `config.projectDir` 优先;当它被省略时(默认 ACP 接线只配置 `configPath`),桥接将该环境变量按每次运行默认为 agent智能体的会话工作区——即钩子已经在其中运行的 `session.header.cwd`——而非留空。这样,一个标准的项目相对路径钩子在默认配置下即可正常工作。
### 隔离
配置在加载时一次性解析;读取/解析失败时记录日志并不注册任何内容,而非崩溃启动(一个拼错的路径不应拖垮 agent。CC 桥接只运行 shell 形式的 `type: 'command'` 钩子;`http``mcp_tool``prompt``agent` 处理器被解析后跳过。Codex 桥接只运行同步命令处理器,跳过 `async: true` 或非命令条目。emit 监听路径(`session-start``subagent/start`)以 detached 方式运行,其 `inject` 包裹在 `.catch` 中记录日志(抛异常的 inject 不得中断会话启动或循环)。
### 钩子在哪里运行,配置从哪里来
钩子在 agent 的会话工作区中运行,因此相对路径指向用户的项目。`configPath` 相对于进程启动时的 cwd 解析一次,适用于所有会话。按会话的项目本地发现仍推迟在 `TODO(per-session-hook-config)` 下。
## 推迟的兼容性缺口
- **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不予执行——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite Agent Note](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为 pre-execution 参数被 `tool/call` 审计、`assistant/message` 历史和 ACP/tool-bash 展示共同读取,诚实的重写是一个设计单元,而非一个字段。
- **Stop 循环防护**`TODO(stop-loop-guard)`。Claude Code 提供 `stop_hook_active` 并在连续八次阻塞后覆盖钩子Codex 提供 `stop_hook_active` 但未记录等效上限。两个桥接始终报告 `false`,因此一个无条件阻塞的 Stop 钩子会在每一步强制继续——在状态追踪落地之前,钩子作者必须自行限制。
- **钩子 `continue:false`(硬停止)。** 钩子可以请求终止整个运行CC/Codex `continue:false`);共享合并将其折叠为 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一同推迟;停止请求记录在 `hook/result` 日志中,钩子在此期间保留其逐点效果(决策/上下文)。
- **配置发现。** 路径在 `cordis.yml` 中显式指定且为进程级(见上文);完整的多层 CC/Codex 优先级遍历、按会话的项目本地发现以及信任/hash 模型未被重新实现(`TODO(per-session-hook-config)`)。
- **Session-start / subagent-start 上下文为尽力而为(`TODO(session-start-gating)`)。** 两个钩子以 detached 方式运行于启动过程之外,因此其上下文在就绪时注入,但可能错过首个请求或短命的 subagent。要保证首请求送达需要一个 awaited 的启动 seam。
## 曾考虑的替代方案
**每点钩子并发执行。** 参考引擎对一个点匹配到的钩子并发运行并折叠结果。本桥接**串行**运行(匹配循环内每个钩子 `await`),并以相同的最严格合并策略折叠。串行是刻意的:它使每个钩子的 `hook/invoked`/`hook/result` 对在会话日志中相邻且顺序确定,而折叠对决策是顺序无关的(`deny > ask > allow`),因此结果一致。代价是延迟(钩子 *N* 等待钩子 *N1*)以及每钩子超时不重叠——对真实配置中的钩子数量可以接受;如果某配置的扇出大到影响总耗时,再重新评估。
## 后果
匹配语义、退出码处理和合并优先级位于 `dsh-hook-protocol`;每个桥接只负责解析配置、构建方言 payload 和映射结果。逐文件覆盖率包含配置分支以及通过真实循环、`dsh-bash-local` 和 shell 脚本的端到端映射,同时一个真实 Loader 冒烟测试守护包的导出形态。原生插件绕过协议格式,直接返回类型化决策。

View File

@@ -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-30-hook-protocol-lib.md: 19a69119befde99417b736edf38923ec6ac5fa7c
2026-06-30-hook-protocol-lib.zh.md: f4950eea2b02e86ed7109f8ebdaab29dd77428d8

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-30-hook-protocol-lib.zh.md)
## Problem
The hooks subsystem ships two bridge plugins: one that runs a user's existing Claude Code (CC) hooks, one for Codex hooks. Studying the reference implementations (`~/repos/refs/claude-code`, `~/repos/refs/codex`) surfaced a decisive fact: **Codex deliberately reimplements a SUBSET of the CC hook protocol.** Its engine reads the same `hooks.json`, uses the same matcher-group shape, the same exit-code/structured-stdout output contract, and the same command-hook execution model — Codex's source even names the engine after Claude's and comments where it "intentionally diverges." So the two bridges would otherwise duplicate the bulk of the protocol.

View File

@@ -0,0 +1,32 @@
# Agent Note: dsh-hook-protocol——Claude Code / Codex 钩子协议格式共享核心库
Status: implemented
[English](2026-06-30-hook-protocol-lib.md) | 中文
## 问题
钩子子系统提供两个桥接插件:一个运行用户既有的 Claude CodeCC钩子另一个运行 Codex 钩子。研究参考实现(`~/repos/refs/claude-code``~/repos/refs/codex`)后发现一个决定性事实:**Codex 有意重新实现了 CC 钩子协议的一个子集。** 它的引擎读取相同的 `hooks.json`,使用相同的 matcher-group 形状、相同的 exit-code/structured-stdout 输出契约以及相同的命令钩子执行模型。Codex 的源码甚至以 Claude 的引擎命名,并在注释中标注了「有意偏离」之处。因此,如果不做抽取,两个桥接插件将大量重复协议逻辑。
本 Agent Note 引入 `@deepseek-ai/dsh-hook-protocol`,一个**库**(不是插件——它不注册也不注入任何东西),持有两个桥接插件共同依赖的真正相同的原语。共享与方言专属之间的分界是本设计的重心。
## 决策
`packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude``dsh-hooks-codex`)拥有真正不同的部分。
**共享(本库):**
- **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop智能体循环抛异常
- **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash``stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdinCC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。
- **解码** — `parseHookOutput(exit, stdout, stderr)`exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdoutexit `2` → blocking error`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript文本记录因此无需抑制见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。
- **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接context/system-messages 按序累积。
- **`hook/*` 会话事件** — `hook/invoked` / `hook/result`,通过声明合并进入 `SessionEventMap`(仅日志,如 `compact/*`——不是 `SurfaceEventType`),配有 `appendHookInvoked`/`appendHookResult` 辅助函数,确保 invoked/result 配对与轮次包含关系在各桥接插件间保持一致。`appendHookResult` 还拥有持久化记录的语义decision 字符串(钩子解析出的 decision否则 `continue:false` 时为 `'stop'`,否则为 `'pass'`)和 500 字符的 `stderrSummary` 截断均从本库的 `HookOutput` 派生,而非各桥接插件各自实现。
**方言专属(桥接插件):** 构建每个事件的 stdin payloadCC 的 base+per-event 字段集 vs Codex 的 snake_case 加 `turn_id`/`model` 额外字段)、方言的 env 与 `${CLAUDE_PLUGIN_ROOT}` 替换CCvs 无替换Codex以及将方言无关的 `HookOutput`/`MergedHookOutcome` 映射为 harness seam 专属的类型化 Decision`PreToolDecision``PromptDecision``ContinuationDecision``PostToolDecision`)。
## 曾考虑的替代方案
**单一参数化引擎。** 否决,因为 payload 构建与 decision 映射在方言间确实不同。Matcher、编解码器、执行、合并规则和事件保持共享每个桥接插件保留自己的 payload 和映射,使其协议格式行为在代码中可就地阅读。
## 后果
每个桥接插件解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。

View File

@@ -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-30-interception-seams.md: 8af489d3f575dd457e1981e433330f08cefa2043
2026-06-30-interception-seams.zh.md: 3886ed21504abfe14a2ccb344d7aa0d3f8d25064

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-30-interception-seams.zh.md)
## Problem
The harness needs a hooks subsystem: users extend or gate the agent at lifecycle points the way Claude Code (CC) and Codex do. The key reframe driving this design is that **"native hooks" are not a package** — a native hook is just an ordinary Cordis plugin subscribing to the canonical lifecycle events. So the real product is a *powerful, well-typed canonical event surface*; the CC/Codex bridges (the `dsh-hooks-claude` / `dsh-hooks-codex` packages) are merely translators that map an external shell-hook protocol onto that same surface. Anything a bridge can do, a plain plugin can do directly — more powerfully (no serialization boundary, full `ctx`, typed returns).

View File

@@ -0,0 +1,61 @@
# Agent Note: 拦截 seam——钩子编程所面对的类型化 Decision 表面
Status: implemented
[English](2026-06-30-interception-seams.md) | 中文
## 问题
harness 需要一套钩子子系统:用户像 Claude CodeCC和 Codex 那样在生命周期节点扩展或管控 agent智能体。驱动本设计的关键视角转换是**「原生钩子」不是一个包**——原生钩子只是一个普通的 Cordis 插件,订阅规范的生命周期事件。因此真正的产品是一个*强大、类型完备的规范事件表面*CC/Codex 桥接(`dsh-hooks-claude` / `dsh-hooks-codex` 包)只是将外部 shell 钩子协议映射到同一表面的翻译层。桥接能做的事,普通插件可以直接做——而且更强大(无序列化边界、完整 `ctx`、类型化返回值)。
该表面需要为以下场景提供各自独立的契约逐提示词策略CC 的 `UserPromptSubmit`、会话启动观测CC 的 `SessionStart`)、工具执行前策略、环绕调度控制、工具执行后变换、最终结果观测,以及携带面向模型的原因的继续执行。如果把这些阶段混为一谈,插件就会获得不需要的 mutation 通道,而终结性将依赖监听器的注册顺序。[事件域语义 Agent Note](../architecture/2026-06-30-event-domain-semantics.md)提供了三域规则与类型化 Decision 惯用法;本 Agent Note 将其应用于生命周期 seam。
## 决策
规范表面将可变换策略、环绕调度控制与仅观测通知分离。策略 waterfall瀑布式事件返回小型的、seam 专属的**类型化 Decision 联合类型**;包装层返回规范化结果;通知接收不可变快照,无法影响结果。覆盖的钩子点包括 `session-start``prompt-submit``pre-tool``post-tool`、通过 continuation 实现的 `stop`,同时将非钩子的执行策略留作独立可组合。
**Agent 事件**`dsh-agent`
- `agent/session-start(agent, source)` ——emit在第 1 轮次之前触发一次,携带 `SessionStartSource``startup` 表示全新/fork 创建,`resume` 表示重新加载的持久化会话;`clear`/`compact` 保留)。纯通知,不能阻塞启动(这是有意的空白:桥接可以记录/注入,但不管控启动)。监听器通过 `agent.inject()` 注入上下文。
- `agent/prompt-submit(agent, content, source, signal, next) → PromptDecision` ——waterfall在轮次唯一取得所有权的排队消息追加为 `user/message` 之前触发。显式轮次 signal 位于最后的 `next` 之前;`allow` 可以重写提示词 `content` 或附加来源各自独立的 `additionalContexts[]`,而 `block` 会追加一条持久的 `prompt/blocked`,并拒绝这个零步骤轮次。
**`agent/turn-continuation`** 接收并返回一个 `ContinuationDecision``{action:'continue', reason?}` 可携带面向模型的内容和来源,记录为同一轮次内的下一步 steering中途引导——与 `/goal` step-end-steer 模式互为类型化孪生。它不是 `context/message`,因此其类型不提供持久上下文元数据。
### 工具流水线为每个阶段赋予一种权限
每次调用遵循 `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute` → 由定义拥有的 `finalizeContent``tools/result`。注册表对调用方输入创建快照、实体化并冻结参数、分配一个不透明 token并在策略开始前对可见定义的最终内容回调创建快照。嵌套调用仅携带父 token。身份始终不可变只有 `signal` 可在环绕调度时改变。日志、UI 和工具体因此对「执行了什么」达成一致。
- **`tools/pre-execute`** 是可扩展的 waterfall 门禁。其 `PreToolDecision` 允许、拒绝或询问。拒绝跳过 `tools/execute` 与核心调度。询问通过可选的审批 seam 解析:只有 `allowed-once` 继续通过 guards 和调度;拒绝、取消、通道不可用、审批服务缺失或无 agent 调用均规范化为拒绝。每个已解析的 decision 仍会到达后策略;抛出异常的监听器会成为最终的规范化失败。
- **`ctx.tools.guard()`** 在整个 pre-execute waterfall 之后安装同步的、作用域感知的策略。guard 可以拒绝或弃权,永远不能强制允许,因此监听器顺序无法复活一个被最终不变式禁止的操作。
- **`tools/execute`** 是用于超时、重试和指标插件的环绕调度 waterfall。包装层通过 `next()` 委托给核心调度,在此之前可以替换并恢复必需的 `exec.signal`,但不能移除它;包装层接收抛出异常或未知工具产生的、已完成规范化的规范成功/失败结果。包装层自行产生的成功结果会短路调度,并通过已解析的输出声明重新规范化。
- **`tools/post-execute`** 是检查/变换 waterfall。其 `PostToolDecision` 接受、以反馈阻止、替换呈现内容或规范值,或附加 `additionalContexts`。替换值会重新校验并重新计算呈现;替换内容会保留程序化值,且不构成保密边界。返回的 decision 是受支持的变换通道。
- **`ToolDefinition.finalizeContent`** 是一个可选、同步、完备且仅能处理内容的边界,在调用创建时随可见定义一起被快照。注册表将候选结果规范化并创建无损快照后,它恰好运行一次;候选结果包括绕过后续 waterfall 的 pre、around 或 post 监听器失败,以及为另一个结果字段创建快照时发现的错误。它可以替换 `content`,也可返回 `undefined` 保留原内容,但不能重写 `isError`、结构化错误身份、上下文或呈现元数据。工具在此执行自身最后一道内容不变式,而无需将策略失败转换为更弱的阻止 decision。
- **`tools/result`** 是在所有变换、无损 JSON 实体化和外层错误边界之后的同步封闭通知。它接收相同的冻结执行身份和权威结果的不可变快照;观测者的失败按监听器隔离,无法改变或拒绝 `ToolRegistry.execute()` 返回的结果。
核心调度与工具体位于规范化边界内部,因此工具、监听器、无效规范值、渲染器/投影器、非 JSON 呈现和身份形状错误均解析为 JSON 安全的 `isError` 结果而非逃逸出轮次。post-execute 监听器因此可以检查一个抛出异常的工具;由定义拥有的最终内容不变式也会覆盖外层流水线与候选结果实体化失败;最终观测者会同时看到执行期间的规范值,以及会话日志能够持久化的确切呈现字段。[规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)定义值/投影与持久性规则。
**`TurnEndReason.rejected`**`dsh-session`):取得所有权的提示词被 `prompt-submit` 阻止的零步骤轮次。
### 三个承重的循环决策
1. **在提示词策略之前开启轮次。** 被阻止的提示词成为零步骤的 `rejected` 轮次,保持封闭性并为 ACPAgent Client Protocol提供持久的终结事件。否决记录 `prompt/blocked`(含原始提示词和原因),而每个允许的 `additionalContexts` 条目都注入到已开启的轮次中。依照[一次 send 对应一个轮次的简化](../simplification/2026-07-17-one-send-one-turn.md),每个取得所有权的 ordinary-send 条目都是其轮次中的唯一消息;启动前丢弃不会创建轮次。
2. **工具执行后的 `additionalContexts` 与异步注入进入活跃批次 FIFO并在该批次结算时追加。** `content`/`feedback` 塑造 `execute()` 返回的结果,但每项上下文都是独立的 `context/message`,而单个步骤或组合工具可以产生许多上下文。立即追加上下文会产生 `result(c1) → context → result(c2)` 的交错,或把嵌套上下文放在外层结果之前,破坏工具调用/结果邻接性。因此 `ToolRunContext.deferContext()` 会在失败路径上也收集嵌套调度上下文,`execute()``ToolExecutionResult` 上暴露有序数组,循环再把它接纳到与执行期间 `agent.inject()` 调用相同的 FIFO 中。FIFO 在批次结算时,于每个已记录结果之后追加,其中也包括被中断轮次关闭之前。被接受的外层调用将 deferred contexts 保留在 decision contexts 之前;被外层阻止时则丢弃 deferred contexts只暴露阻止 decision 显式提供的上下文。
3. **强制 `continue` 的 `reason` 通过 steering 通道入队**,使得下一步骤在循环顶部排空时将其记录为当前轮次的 steering——同一轮次内的下一*步骤* steering而非下一*轮次*的提示词(与现有的 `hasSteering` 强制继续覆盖一致)。
### 工具执行前输入重写是一个独立的一致性决策
`PreToolDecision` 不能重写参数。历史和审计调用在执行前记录ACP 展示读取相同的输入,因此注册表在策略之前封存参数。有效的重写必须在身份创建之前同时更新历史、审计、展示和执行;该契约属于[输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)。
### 边界
seam 包**不**声明 `hook/*` 会话事件(持久的钩子调用日志);那些属于 `dsh-hook-protocol`,因为原生插件使用类型化 decision 而无需外部钩子日志。原生插件集成测试(`packages/core/agent-loop/tests/interception.spec.ts`)通过真实循环组合这些 seam不涉及 `hook/*` 协议。压缩compaction`PreCompact`/`PostCompact`、Notification 和 Codex `PermissionRequest` 不在本决策范围内。[审批 seam](2026-07-06-approval-seam.md) 通过 `ctx.approval` 解析 `ask` decision而终结性的单调停止由 `agent/turn-stop` 独立负责。
## 曾考虑的替代方案
- **将工具执行前输入重写作为本 seam 集的一部分发布**:推迟,视为越界信号;上文已阐述一致性问题(审计、历史和展示都读取执行前记录的 `tool/call.arguments`[工具执行前输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)负责该设计。
- **将持久的 `hook/*` SessionEvents 与 seam 一起声明**:否决。原生插件使用类型化 Decision 而完全不需要钩子日志(实际示例已证明),因此持久日志属于[钩子协议库](2026-06-30-hook-protocol-lib.md),而非 seam 表面。
## 后果
规范拦截表面具有统一的类型化,同时不给每个扩展相同的权力:钩子返回 decision执行包装层做包装终结 guard 只能拒绝,最终观测者只能观测。循环负责 session-start、prompt-submit、工具执行后上下文缓冲和 continuation`dsh-tools` 负责身份封存与五阶段执行流水线。它们的契约记录在 [architecture.md](../../../../docs/architecture.md)、各包 README、[核心拦截 decision](../../../../docs/core-data-structures/core.md#interception-decisions) 与[工具结构](../../../../docs/core-data-structures/tools.md)中。ACP 桥接将 `rejected` 轮次映射为其 `cancelled` 编解码值,而钩子驱动的快照端到端验证可观测的桥接行为。

View File

@@ -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-30-session-store-fork-api.md: 4e4ea7f1d3aa3290bdb21999a292dbdfd016302e
2026-06-30-session-store-fork-api.zh.md: d17daa464ad59c6b5ac4d5041b4af30c44321fa7

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-30-session-store-fork-api.zh.md)
## Problem
The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level: `ctx.sessions.create(id, { seed, meta })` accepts any valid seed, but ordinary live-session branching needs policy around which prefix can be copied, which metadata is stamped on the child, and how errors are classified.

View File

@@ -0,0 +1,43 @@
# Agent Note: SessionStore fork API
Status: implemented
[English](2026-06-30-session-store-fork-api.md) | 中文
## 问题
事件溯源的会话日志已经具备 fork 所需的原语:创建一个带有种子事件前缀的新会话,然后像回放一样从该种子日志推导模型历史。这个原语有意保持底层:`ctx.sessions.create(id, { seed, meta })` 接受任何合法种子,但常规的活跃会话分支需要围绕以下问题制定策略:哪些前缀可以被复制、子会话应打上哪些元数据、以及错误如何分类。
语义上的风险在于 fork 边界。一个合法的用户可见 fork 种子必须是连续的且封闭在轮次内。如果在一个活跃轮次内部 fork会复制一个未关闭的 `turn/start`、可能还有一个未关闭的 `step/start`,以及可能悬空的工具调用。这违反了轮次封闭性与提供方 transcript 不变式,并且会创建一段误导性的子历史——看起来子会话参与了父会话中一个尚未完成的轮次。现有的 [subagent seam](2026-06-21-subagent-capability-seam.md) 有意解决的是另一个问题:工具触发的 subagent fork 通常发生在父轮次仍然打开时,因此 `dsh-subagent-fork` 会将种子裁剪到父会话最后一个已完成轮次的前缀。通用的会话 fork 不应静默裁剪;它应当要么在请求的边界处 fork要么拒绝请求。
## 决策
`dsh-session` 直接在 `ctx.sessions` 上拥有常规活跃会话 fork 的能力。不设独立的 `dsh-session-fork`package也不设 `ctx.sessionFork` 服务:该 API 没有独立的后端、事件词汇、生命周期或持久化行为,所有持久化工作都委托给现有的会话存储和持久化后端。
store 暴露一个操作:
```ts ignore-check
type SessionForkSource = Session | SessionId
class SessionStore extends Service {
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
}
```
`boundary` 是要复制到的源事件 `seq`(含该序号)。省略时默认为源会话当前的最后一个事件;对空源会话省略 `boundary` 则创建一个空的子会话。fork 特有的校验仅检查请求的边界是否存在且为 `turn/end`。选定的前缀随后被深拷贝到子会话的种子中。子会话继承源会话的 `cwd`,将 `parentSession` 设为源会话 id并将 `seedLength` 设为已复制前缀的长度。省略 `childSessionId` 时,`SessionStore` 使用其现有的 id 策略生成一个。
空前缀可以被 fork任何非空边界都必须是一个安全的、已存在的、位于 `turn/end` 的序号,无论结束原因为何。类型化的错误区分源缺失、对象陈旧、子 id 重复和边界无效等情况。更广泛的日志校验与崩溃恢复仍由其现有的负责方处理。
## 曾考虑的替代方案
**独立的 `ctx.sessionFork` 服务。** 这是最初的实现,但评审表明它过度套用了 capability-seam 模式。代码没有可替换的后端、没有额外的事件面、没有独立的所有权生命周期,也没有超出 `ctx.sessions.create({ seed, meta })` 的持久化行为。保留独立包会迫使调用方为了在会话存储原语之上执行一层策略而去发现并安装第二个服务。
**两个函数:`snapshot()` 加 `fork()`。** 这保留了一个可复用的种子/元数据计算,但唯一支持的消费方会立即创建会话。它还使接口看起来比用户实际需要的具体操作更抽象。单一的 `fork()` 加显式 `boundary` 使 API 保持直接,同时仍支持对先前时间点的 fork。
**静默裁剪未关闭轮次到最后一个已完成边界。** 这对 `dsh-subagent-fork` 是正确的——委托通常在父轮次仍然打开时开始,子会话应只继承已完成的前缀。但对常规的用户/会话分支而言是错误的,因为它隐藏了请求的 fork 点实际上不是合法边界这一事实,并且静默丢弃了父轮次的尾部。
## 后果
公开接口保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作fork 出的子会话以种子事件开始生命,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession``seedLength`。
v1 范围仍然排除 ACPAgent Client Protocol `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具以及 subagent 重构。如果未来添加 ACP 方法,应在具备 transcript文本记录/快照覆盖后才广播该能力;本 Agent Note 不添加面向编辑器的更新,因此当前不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 则获得专门的 `dsh-session` 单元测试加 JSONL 持久化覆盖。

View File

@@ -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-30-subagent-observe-enrich.md: a07cef95630689d1ca8cacd3eb7c50e691cb304a
2026-06-30-subagent-observe-enrich.zh.md: 578aae0a7273defcc1f88fb2a50c83ef454e3c16

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-06-30-subagent-observe-enrich.zh.md)
## Problem
The hooks subsystem ([interception seams Agent Note](2026-06-30-interception-seams.md)) lets a plugin observe and gate the agent at lifecycle points. Claude Code and Codex both expose **SubagentStart / SubagentStop** hooks, and CC's carry the subagent's final message. The harness already emits `subagent/start` and `subagent/end` lifecycle events ([the subagent capability-seam](2026-06-21-subagent-capability-seam.md)), but their payloads were minimal (`provider`, `id`, and on end `stopReason`) — not enough for a hooks bridge to report WHAT a subagent produced without separately reaching for the live run.

View File

@@ -0,0 +1,31 @@
# Agent Note: Subagent 生命周期丰富化——lastAssistantMessage仅观察
Status: implemented
[English](2026-06-30-subagent-observe-enrich.md) | 中文
## 问题
钩子子系统([拦截 seam Agent Note](2026-06-30-interception-seams.md))允许插件在生命周期节点观察和拦截 agent智能体。Claude Code 和 Codex 都暴露了 **SubagentStart / SubagentStop** 钩子,且 CC 的钩子携带 subagent 的最终消息。harness 已经发出 `subagent/start``subagent/end` 生命周期事件([subagent 能力 seam](2026-06-21-subagent-capability-seam.md)),但其载荷极为精简(`provider``id`,以及 end 时的 `stopReason`),不足以让钩子桥接层在不单独访问活跃 run 的情况下报告 subagent 产出了什么。
本 Agent Note 丰富 end 载荷。它刻意限定为**仅观察**:不改变控制流,不引入 waterfall瀑布式事件。影响 run 的 subagent-stop 决策(续行、改变 run 的注入)属于另一个更大的重设计,不在本 Agent Note 范围内。
## 决策
**在 `SubagentRunEndInfo` 中添加 `lastAssistantMessage`——子 agent 的最终输出。** 在正常结束路径上,它是只读的类型化 `SubagentResult.output`,观察者无需持有 run 即可看到子 agent 产出了什么。在基础设施拒绝(不存在 `SubagentResult`)的情况下,该字段缺失,事件报告 `stopReason: 'error'`。提供方与监听方是受信任的同进程协作者,遵守借用不可变载荷的契约。
两个事件仍为普通 **`emit`**。异步的 `SubagentService.start()` 将结果观察附加到就绪的提供方 run 上,发出 `subagent/start`,然后返回该 run进程内监听方因此可以通过 `ctx.agents.get(info.id)` 访问已发布的子 agent而远程提供方无需在本地注册表中有对应条目。提供方启动被拒绝时不发出任何事件。回调保持仅观察且逐监听方隔离确保一个异常订阅者不会阻塞活跃 run 或饿死后续监听方。
## 曾考虑的替代方案
**`agentType` subagent 类别标签**CC 的 `subagent_type` 在 harness 中的对应物),放在请求与两个生命周期载荷上。早期草案曾包含它;评审中移除,因为它是 Claude Code 的概念,不适合我们自己的 seam此处没有任何逻辑解释它唯一消费方是 CC 方言桥接层。CC 桥接层改为直接为其 SubagentStart/Stop 的 `agent_type` matcher 填入 Claude Code 自身的默认值 `"general-purpose"`,因此本 Agent Note 只交付**一项**丰富化:`lastAssistantMessage`
**控制流式 `subagent/end`**:推迟;见下文。
## 为何仅观察,以及推迟了什么
控制流式 `subagent/end`(一个被 await 的 waterfall返回停止/继续决策,与其他拦截 seam 一致)需要:将 `subagent/end` 从 emit 改为 waterfall、重构 `SubagentService.start` 使其在结算前 await 监听方、在进程内提供方中实现 `resume` 能力以便「继续」能真正重新运行子 agent。这属于[能力 seam Agent Note](2026-06-21-subagent-capability-seam.md) 已推迟的后台/steering中途引导subagent 重设计(同一个重设计还将统一 subagent 与 bash 之间的长时间运行工具处理)。本 Agent Note 交付钩子桥接层当前所需的仅观察丰富化;`FIXME(subagent-continuation)` / `TODO` 锚点标记了控制流版本在重设计发生时的落点。
## 后果
钩子桥接层(或原生插件)现在可以通过订阅既有 emit 将子 agent 的 `lastAssistantMessage` 转发给 SubagentStop 处理器,无需新的控制流接口。词汇新增记录在 [docs/core-data-structures/subagent.md](../../../../docs/core-data-structures/subagent.md)(事件行文部分)与两个 subagent README 中catalog 已重新生成。生产行为无变化——事件触发方式与之前完全一致end 载荷上多了一个可选字段——因此无需更新快照或 e2e 测试。

View File

@@ -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-dynamic-workflows.md: 4c8606fb617b3fb6b2e8ad9c35d77c585b1fe8b1
2026-07-05-dynamic-workflows.zh.md: 757eafb5d21f42b2ca529ebeb6b16eb8205e823d

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-05-dynamic-workflows.zh.md)
## Problem
The harness can delegate ONE task to ONE child (`dsh-tool-subagent`), but work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — forces the model to orchestrate turn by turn: every intermediate result lands in the parent context, the plan lives nowhere durable, and coordination costs a model round-trip per step. Claude Code ships this capability as [dynamic workflows](https://code.claude.com/docs/en/workflows): the model writes a JavaScript orchestration script, a runtime executes it, and the script — not the conversation — holds the loop, the branching, and the intermediate results.

View File

@@ -0,0 +1,80 @@
# Agent Note: 动态工作流——脚本驱动的多 agent 编排 seam
Status: implemented
[English](2026-07-05-dynamic-workflows.md) | 中文
## 问题
harness 可以将一个任务委派给一个子 agent`dsh-tool-subagent`但需要扇出到多个独立部分的工作——跨多文件审计、迁移、多角度调研、对抗式验证——迫使模型逐轮次编排每个中间结果都落入父上下文计划无处持久存储每一步的协调都要消耗一次模型往返。Claude Code 以[动态工作流](https://code.claude.com/docs/en/workflows)的形式提供了这一能力:模型编写一段 JavaScript 编排脚本,运行时执行它,由脚本(而非对话)持有循环、分支和中间结果。
## 决策
`packages/workflow/` 下以 bash seam 的形态(接口/实现/消费方)提供一组工作流能力,以及它在 subagent seam 上所需的结构化输出基础。
### 脚本契约(兼容 Claude Code
一次工作流调用包含 JSON `meta``name``description`,以及可选的 `whenToUse`/`phases`)和一段支持顶层 `await` 并返回 JSON 值的 JavaScript `script` 正文。元数据作为数据校验,从不被执行。正文接收 `agent(prompt, options)``parallel(thunks)``pipeline(items, ...stages)``phase(title)``log(message)``args`。流水线各阶段接收 `(prev, item, index)`,阶段之间无屏障;失败的子 agent 和普通阶段错误将受影响的 item 解析为 `null` 并跳过其剩余阶段。Claude Code 的确定性限制通过日志化延迟处理,因此兼容的脚本正文在将 meta 头移入参数后可以使用时钟和随机数。
与 CC 有一处刻意的严格性差异:钩子误用——未知或延迟的选项(`effort`/`isolation`/`agentType`)、格式错误的参数、超出支持子集的 schema、触发上限、seam 启动失败——会抛出带 `fatal: true``WorkflowError`,组合器会重新抛出 fatal 错误而非将 item 置为 null。如果不这样做一个拼错的选项会悄然变成一个与子 agent 失败无法区分的 `null`——这正是本仓库禁止的「被接受后被忽略」的失败模式。另有一处新增:工具的 `args` 参数是一个 JSON 对象裸列表被包装为一个字段使协议格式wire format保持诚实。
### seamdsh-workflow
`ctx.workflows` 是 bash 形态的抽象 `WorkflowService`——每个上下文一个引擎,无命名提供方注册表(引擎是部署级替换,不是共存者)。`start(request)` 对无法启动的脚本同步抛出;返回的 `WorkflowRun``result` 永不 reject失败解析为 `stopReason: 'error' | 'cancelled'`)。`workflow/*` 事件是仅观察的 emit携带数据快照id + meta`workflow/end` 省略 result 值),按监听器隔离,与 `subagent/start`/`subagent/end` 对称——控制权留在 run 的持有者手中。词汇详情见 [core-data-structures/workflow.md](../../../../docs/core-data-structures/workflow.md)。
### 引擎dsh-workflow-workerthread每次运行一个 worker 线程
**信任前提**:工作流脚本与模型的 bash 访问具有相同的信任级别。引擎容纳有缺陷的脚本,并保证结果已 settled、值为 JSON 安全、取消后完全停稳它不防御恶意代码。vm 上下文和 worker 线程不是安全边界:脚本可以逃逸到具有进程级权限的 Node API。沙箱化需要在此 seam 背后使用独立进程或 isolated-vm 引擎。
**为何选择 `node:worker_threads`**:每次运行获得一个非池化的 worker。vm 上下文限制了文档化的脚本表面,而消息端口 RPC 将 `agent()` 桥接到宿主侧的子循环。worker 防止脚本的同步工作阻塞宿主,提供序列化边界,并允许取消后强制终止。`isolated-vm` 因其维护状态和部署要求被否决。
宿主在发布前校验元数据并解析正文。私有枚举键 payload 映射定义协议格式待启动记录、已发布子记录、单一取消信号、worker 死亡回收、结果优先级与 dispose 完全停稳,在此协议上保持 subagent run 契约。这些竞态算法归[agent 作用域运行时设计 Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records)所有。
引擎暴露一条进程内 `MessageChannel` 测试路径,因为主进程 V8 覆盖率无法观测 worker 执行。
**Meta 是数据**:经 schema 校验的 `meta` 字段以 JSON 形式到达 seam仅做形状校验。宿主从不执行元数据字面量否则脚本控制的访问器可以在 worker 隔离之外运行。
**值边界**`materializeFromRealm` 复制出站值并拒绝函数、symbol、嵌套 `undefined`、异域原型、循环引用、稀疏数组和非有限数字。数据属性复制使 `"__proto__"` 安全getter 正常读取,抛出异常的 getter 会大声失败。`args` 通过 `workerData` 传入暴露前再次克隆。realm 函数被调用而非复制,抛出的值使用全量渲染器,因此 `result` 不会 reject。钩子错误是宿主 realm 的 `WorkflowError`,脚本应基于 `name``code` 分支而非 `instanceof Error`,如引擎 README 所述。并发、total-agent、item、超时和宽限限制均为经校验的配置。
### 消费方dsh-tool-workflow
一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态启动、await、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed``isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述即面向模型的编写规范。使用策略以工具自身的 `tool:<toolName>` 提示词段落随工具发布(显式请求才使用的引导——工具引导存在于工具插件中,从不在部署 persona 中harness 没有 ultracode 风格的 effort 门控。
### 基础subagent seam 上的结构化输出
`SubagentStartRequest.outputSchema``dsh-subagent-inprocess` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同的 schema 而不共享可变策略dispose 子 agent 时移除整个附件。
输出 schema 使一次 schema 有效的已提交捕获成为子 agent 成功完成的必要条件。作用域运行时呈现捕获工具和指令,仅提交成功的最终结果(包括 SDK 调用时外层 `run_code` 的结果),在捕获变为 pending 后拒绝后续副作用,并在提交后不再进行模型步骤即停止子 agent。校验失败仍是可重试的工具错误没有已提交捕获的正常完成以错误结算。
`ObjectJsonSchema``dsh-tools` 统一且可强制执行的原始 JSON Schema 子集所提供的对象根消费方视图;不支持的关键字会大声失败,因为该协议数据会逐字成为捕获工具的 parameters。[统一 JSON 值 schema Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md)定义词汇与校验语义,[agent 作用域运行时设计 Agent Note](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes)则定义组装、提交、守卫和终止停止算法。
## 测试
worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够度量它。单元测试覆盖脚本辅助函数、fatal 与 nullable 失败、JSON 边界、上限、取消、子 agent 所有权和通过真实循环的结构化输出。built-bin 冒烟测试在纯 Node 下运行单独打包的 `lib/worker.cjs`,带密钥的 e2e 驱动真实子 agent面向模型的工作流行为通过其所属示例进行快照覆盖。
## 延迟(本轮明确的非目标)
- **后台收集**(启动工具 → run id → 完成通知 → 收集),与 bash/subagent 后台统一一起设计。
- **日志化 + 恢复**`resumeFromRunId`、缓存的 agent() 前缀):实现它会以脚本契约收紧的形式重新引入 CC 的确定性禁令(脚本目前可以读取时钟)。
- **保存/打包的工作流**`.deepseek/workflows/` 注册表、斜杠命令界面)和**脚本持久化到运行目录**(工具调用事件已经持久记录了脚本)。
- **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都以命名延迟的消息大声拒绝)。
- **整体运行的挂钟超时**取消总能释放调用方result 在宽限期内 settle因此总运行时间上限是后台重设计的策略旋钮不是此处的正确性需求。
- **超越 worker 线程的引擎加固**:在同一 seam 背后使用 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。
- **ACP 进度 UI**(基于 `workflow/*` 事件的 `/workflows` 风格视图);事件已为此而存在。
- **ACP 后端结构化输出**和 **`toolFilter`**(两者仍以能力标志 `false` 门控)。
## 曾考虑的替代方案
- **宿主侧的恶意值防护**(无 trap 代理拒绝、从不调用访问器的描述符遍历、realm 侧预渲染抛出值、realm 构建的 promise/array/error 克隆加结构化 fatal 识别):否决。每项防御针对的都是信任前提所接受的作者,而线程的序列化边界已经从构造上使跨 realm 值全量化。
- **进程内 `node:vm` 执行**:机械上最简——无 RPC、无线程——但 `start()` 会在脚本的初始同步切片期间阻塞调用方,第一个 await 之后的同步自旋无法在进程内终止vm `timeout` 仅覆盖第一个切片),且 `dispose()` 只能在宿主循环上放弃一个未 settle 的脚本。worker 线程引擎保持相同的 vm 上下文脚本表面,同时解除宿主阻塞并使终止成为现实。
- **后台执行作为默认**CC 的形态):延迟。前台同步与 `dsh-tool-subagent` 的当前形态一致,后台语义应在 bash、subagent 和工作流之间统一设计一次,而非逐工具设计。
- **工作流层为 `agent({schema})` 做 JSON 解析**:在一个消费方重复 seam 关注点,而 seam 的能力标志仍不诚实地为 `false`
- **Meta 嵌入脚本中作为 `export const meta = {...}`**CC 的确切格式):保持脚本自包含且 CC 脚本可直接使用,但获取 meta 需要在宿主上执行模型编写的文本。即使一个空的限时 vm 上下文也无法约束脚本控制的 getter当宿主读取结果对象时。JSON 参数消除了扫描器、执行和宿主自旋漏洞;代价是 CC 脚本的 meta 头必须移入参数(正文保持可直接使用)。
- **`ValueSchemaSpec` 作为 `outputSchema` 协议类型**:面向作者的形式如今具有等价词汇,但工作流提供的是来自其他 realm 的原始 JSON Schema 数据;将这类运行时数据假装成可信的作者声明,会跳过原始 schema 断言边界。
- **schema 对象库zod 或本仓库的 schemastery用于结构化输出子集**schema 是协议数据——纯 JSON跨越 `agent({schema})` 中的 vm realm 边界并逐字落入强制工具的 parameters——正是活 schema 对象无法存在的位置;在运行时消费原始 JSON Schema 需要在其上加一个第三方转换器zod core 只输出 JSON Schema不能反向且会在 schemastery 的配置角色旁边放置第二种 schema 语言。
- **ajv 用于值校验**:它校验完整 JSON Schema因此子集门控——模块的真正要点因为每个被接受的关键字都必须是 harness 强制执行的——无论如何仍需手写;它通过 `new Function` 编译校验器;且它将成为 dsh-tools 的第一个运行时依赖,仅为替换约 70 行的值遍历器,而路径限定的、报告每一处违规的错误报告无论如何都是自定义的。
- **提供方 JSON 模式代替捕获工具**:它保证有效 JSON不保证 schema 一致性,且它与工具调用的交互不明确。捕获工具保留了轮次内的校验重试。提供方侧的严格工具 schema 后续可以在不改变本设计的情况下收窄接受的子集。
## 后果
扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和消息端口 RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker序列化强制执行值边界。worker 线程不是安全边界。无效选项快速失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。

View File

@@ -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-skill-system.md: 3c328adf09ab0f6ad3388a01bac3661d45c9247e
2026-07-05-skill-system.zh.md: ba30a3735b73b00afb46baa24f969fd687a759b5

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-05-skill-system.zh.md)
## Problem
Agent products have converged on a skill pattern: keep the request prompt small by listing only available instruction bundles, then load the full body when the model decides a task matches. Codex, Claude Code, OpenCode, and Kimi Code differ in details, but all separate discovery metadata from complete instructions so a workspace can carry reusable behavior without paying the full prompt cost on every turn.

View File

@@ -0,0 +1,55 @@
# Agent Note: Skill 系统——面向 agent 的渐进式指令披露
Status: implemented
[English](2026-07-05-skill-system.md) | 中文
## 问题
Agent智能体产品已趋同于一种 skill技能模式保持请求提示词精简仅列出可用的指令包当模型判定某任务匹配时再加载完整正文。Codex、Claude Code、OpenCode 与 Kimi Code 在细节上各有不同,但都将发现元数据与完整指令分离,使工作区能承载可复用的行为而无需在每个轮次支付全量提示词开销。
DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和工具使用指南存放在工作区或用户的 agent 配置旁,而非硬编码到 agent loop智能体循环中。
## 决策
`@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills``@deepseek-ai/dsh-skill-local` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责会话前缀目录与面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 stdio 与 ACPAgent Client Protocol应用获得相同行为同时嵌入式或远程提供方可在不修改注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry``local``tool` 分支分别转发给对应的所有者。
提供方插件在 `apply()` 期间同步注册。提供方成员资格是由直接 effect 持有的状态:注册与 dispose资源释放同步地使已完成的目录失效发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从等待的 `list()` 调用返回排序后的候选项,远程提供方在此过程中执行初始化、认证和发现,同时遵守查找的 abort 信号。注册表校验每个候选项,按排名、提供方注册顺序和提供方内部顺序以先到先得方式解决同名 skill 冲突,然后按 skill 名称排序摘要以保证消费方获得确定性结果。它仅缓存已完成的目录快照,并在发现过程中提供方/运行时修订版本发生变化时重试,因此卸载操作不会将一个陈旧且不可解析的 skill 冻结到会话前缀中。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project 优先于 user 的优先级;`runtime` 保留为注册表拥有的提供方名称。
本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents``customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`以免系统拥有的目录被当作普通用户内容处理。DeepSeek Harness 不随附内置系统 skill嵌入式或远程提供方在配置后提供额外 skill。
每个 skill 是 `<name>/SKILL.md` 或带 YAML frontmatter 的 `<name>.md``name``description` 为必填;`whenToUse``disableModelInvocation``metadata` 为可选。名称采用 kebab-case。YAML frontmatter 使用 `yaml`package解析而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML要么膨胀为一个未经评审的 YAML 子集。
本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve``stat` 探测 `.git`,根目录发现使用 `listDir`skill 读取使用 `readText`。Node 文件系统作为后备,供在不挂载 fs seam 的最小上下文中加载 `dsh-skill-local` 时使用。缺失的根目录、不可读或格式错误的 skill 文件、以及提供方 `list()` 的瞬态失败均降级为警告并跳过,使一个坏源不会导致所有 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。
`dsh-tool-skill` 通过 [`agent/session-prefix`](2026-07-07-session-prefix.md) 贡献一个 user-role `<system-reminder>` 目录。该目录仅包含排序后的 skill 名称与描述不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`。session-prefix seam 将仅用于请求的目录按 loop 实例冻结,并记录在请求头中,在不将其加入持久化历史的前提下保持可重建性。完整的 skill 正文从不包含在目录中。
`skill({ name })` 工具为当前 agent cwd 加载一个完整 skill返回包含 `<skill_content name="...">``<skill_resources>``<skill_instructions>` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和标记了 `disableModelInvocation` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。
数据结构与目录/工具契约记录在 [skills.md](../../../../docs/core-data-structures/skills.md) 中,服务签名见生成的[服务目录](../../../../docs/cordis-catalog/services.md)。
## 曾考虑的替代方案
**将完整 skill 正文注入每条系统提示词。** 否决,因为这破坏了渐进式披露,使每个请求都为可能不适用的指令付出代价。
**仅以斜杠命令暴露 skill。** 否决,因为模型主动加载是核心能力;斜杠/ACP 命令广播不改变发现机制。
**将本地文件系统扫描直接放入 `ctx.skills`。** 否决,因为编码 agent、Web agent 和未来的插件生态需要不同的 skill 来源。提供方注册表与 subagent seam 镜像:注册表拥有冲突解决和消费方,实现拥有加载。
**使用系统提示词段落。** 否决,因为渲染后的系统提示词是单一字符串,而目录是一条具有仅请求生命周期要求的 user-role `<system-reminder>` 消息。[`agent/session-prefix`](2026-07-07-session-prefix.md) 是选定的机制:它将目录置于派生历史之前,并将组合后的消息记录在请求头中。
**在 `~/.dsh/skills/.system` 下物化内置 DSH 编写 skill。** 否决,因为打包的 skill 不应在启动时写入用户主目录,嵌入式或远程提供方在配置后提供 skill。
**递归发现嵌套的 `**/SKILL.md`。** 否决。扁平文件和一级目录包覆盖了配置的根目录,同时使重复处理和目录顺序易于推理。
**手写 frontmatter 解析器。** 否决,因为已接受的 schema 包含一个开放的 `metadata` 对象。窄解析器要么拒绝用户预期可用的合法 YAML要么膨胀为一个未经评审的 YAML 子集。
## 后果
agent-core 主干包含一个 session-prefix 贡献者、一个本地提供方和一个面向模型的工具。Skill 发现是 cwd 敏感的,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。
目录对于固定的根目录集合和运行时注册修订版本是确定性的,但不监视磁盘变化;发现结果被缓存,直到运行时注册使缓存失效或进程重启。
## 延后
Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段,`user-invocable` frontmatter 字段同样不会被解析。直接用户调用本身则作为消费方层面的能力交付TUI 前门基于注册表现有的 `list()` 与 `get()` 方法提供手动 `/skill:<name>` 命令,无需变更注册表、提供方或工具契约——见 [TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)。

View File

@@ -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-approval-seam.md: e28587e0185530164f300562f7dd5e57255ac515
2026-07-06-approval-seam.zh.md: 94ffb330cd36ae88975e8ae9d579b3b4606a5c25

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-06-approval-seam.zh.md)
## Problem
Two callers need to put one question — "may this specific action proceed?" — to a human: `tools/pre-execute`'s `ask` decision (including the Claude-Code hook bridge's `permissionDecision: ask`) and the [sandbox Agent Note](2026-07-06-sandbox.md)'s post-denial one-shot escalation retry. A shared seam keeps them from inventing separate outcome vocabularies, UI routing, cancellation, and audit trails, while guaranteeing that a deployment with no UI can never grant an unanswerable request.

View File

@@ -0,0 +1,139 @@
# Agent Note: 审批 seam——基于 waterfall瀑布式事件应答者的一次性权限决策
Status: implemented
[English](2026-07-06-approval-seam.md) | 中文
## 问题
两个调用方需要向人类提出同一个问题——「这个具体操作可以继续吗?」:`tools/pre-execute``ask` 决策(包括 Claude-Code 钩子桥的 `permissionDecision: ask`)以及[沙箱 Agent Note](2026-07-06-sandbox.md) 中拒绝后的一次性升级重试。一个共享的 seam 使它们无需各自发明独立的结果词汇、UI 路由、取消机制和审计轨迹,同时保证没有 UI 的部署永远不会批准一个无法应答的请求。
路由问题的核心是归属:审批提示必须到达拥有发起请求的 agent智能体的编辑器会话ACPAgent Client Protocol桥在一条连接上多路复用 N 个会话),对无人拥有的 agent进程内 subagent、测试失败关闭并且不侵入没有组合 UI 的部署headless、CI
## 决策
一个包 `dsh-user-approval``packages/ui/user-approval`),拥有词汇表和 `ctx.approval` 服务——即机制。策略——谁来应答、某个会话是否需要被询问——不在其中:应答者是 `approval/request` waterfall 监听器由拥有通道的插件注册ACP 桥、未来的终端 UI、测试脚本而每会话的策略层可以在任何人类介入之前做出决定。消费方`dsh-tools` 的 ask 路由、沙箱升级门禁)将问题解析为一个封闭结果,并从中派生各自的工具结果。刻意设计为一个包,而非能力 seam 的三包拆分(见「替代方案」)。
### 部署如何使用它
一条 `cordis.yml` 条目挂载该 seam。不加载它就是失败关闭的退出方式消费方在没有注册任何审批代码的情况下拒绝无法应答的请求。
```yaml
- id: approval
name: '@deepseek-ai/dsh-user-approval'
# config:
# policy: never # deployment default for sessions without an override; 'ask' when omitted
```
仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用被拒绝——失败关闭无需配置。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.md))即可闭环:其桥注册一个应答者,通过 `session/request_permission` 向拥有该会话的编辑器发出提示,于是钩子的 `ask` 或升级请求会以一次性 Allow/Reject 提示的形式呈现,附着在已流式输出的工具调用上。`policy: never` 是无人值守姿态:每次 ask 确定性地自动拒绝,在系统提示词中声明,无人类参与。`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。
组合部署的可观测行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;轮次内成功的请求会在发起请求的 agent 的会话日志上落一对持久的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。空闲时的请求或审计追加失败会拒绝,而不会返回未经审计的决策。
以下是该组合下的一次 ask逐字取自沙箱示例录制的 `escalation-approved` 场景——模型请求沙箱升级,门禁发起 ask桥向拥有该会话的编辑器发出提示用户点击 Allow once
```
tool/call bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt",
"sandbox_permissions": "workspace-write",
"justification": "the user asked to write escalated.txt in the workspace"}
approval/asked {"toolName": "bash", "callId": "call_00_…",
"reason": "escalate sandbox to workspace-write: the user asked to write escalated.txt in the workspace"}
→ session/request_permission {"toolCall": {"toolCallId": "call_00_…"},
"options": [{"optionId": "allow-once", "name": "Allow once", "kind": "allow_once"},
{"optionId": "reject-once", "name": "Reject", "kind": "reject_once"}]}
← the user picks "Allow once" on the prompt the editor attaches to the streamed bash call
approval/decided {"outcome": "allowed-once"}
tool/result "escalated" — this one call ran under the wider mode; the grant died with it
```
`escalation-rejected` 孪生场景以 `{"outcome": "rejected"}` 结束:不执行任何操作,模型的结果携带发起方的逐字失败关闭文本(`the user rejected escalating this command to "workspace-write"`)。钩子的 `permissionDecision: ask` 走完全相同的协议;只有发起方和拒绝文本不同(§ dsh-tools 中的 Ask 路由)。在 headless 环境下,同一请求完全跳过提示,直接结算为 `unavailable`
### 设计细节
#### seam机制与策略分离
经过校验并成功追加 `approval/asked` 后,服务将 `approval/request` waterfall 解析为 `allowed-once``rejected``cancelled``unavailable`。服务借用只读的请求标识和 signal将中止视为 `cancelled`,把应答者失败和无效返回容纳为 `unavailable`,丢弃迟到的应答,并追加配对的 `approval/decided` 事件。提交前的审计失败会拒绝;追加后的观察者失败无法撤销权威事件。`allowed-once` 仅授权所询问的操作,而 `request()` 会拒绝打开轮次之外的调用,以保证审计对留在持久提交边界内。
应答者是 `approval/request` waterfall 监听器。零监听器会一路委派至 `unavailable`;识别该 agent 的监听器占用先到先得的决策槽,而不识别的监听器必须调用 `next()` 委派。监听器随其 fiber dispose因此卸载通道会失败关闭。由于兄弟插件的注册顺序不确定部署应组合一个终端应答者并保留 `prepend` 给「决策或委派」门禁。
`ApprovalRequest` 携带发起请求的 `agent``toolName`、可选的精确 `callId`、人类可读的 `reason` 和可选的 `signal`。它使用 `CallId` brand 而不导入依赖本 seam 的 `dsh-tools`。工具参数仍留在 UI 通过 `callId` 引用的、已流式输出的调用上。
#### dsh-tools 中的 Ask 路由
`ToolRegistry.execute()` 在派发前解析 `ask``allowed-once` 继续执行,而拒绝、取消和通道不可用产生三种不同的拒绝原因。机会性消费 `ctx.get('approval')`,让缺失或未挂载的服务失败关闭而不阻塞注册表 fiber。无 agent 的执行同样失败关闭,因为它既没有审计会话,也没有 UI 所有者。
#### 每会话策略层
seam 还拥有[沙箱 Agent Note](2026-07-06-sandbox.md) 所描述的会话级 `'ask' | 'never'` 策略。生效策略由日志中记录的切换在部署默认值之上折叠而成。`'never'` 会在任何应答者运行之前,于 `request()` 内部解析为 `rejected``'ask'` 则派发请求,否则一路委派至 `unavailable`。提示词仅声明确定性的 `'never'`,切换叙述会被合并,每个请求仍记录审计对。
#### ACP 应答者
ACP 桥只应答其正向会话映射所拥有的精确 agent 对象。它把 `session/request_permission` 附着到既有 `callId`,声明一次性的 allow/reject 选项,单独映射取消,并且绝不批准未知选项。外部或无调用标识的请求会委派;客户端 RPC 失败变为 `unavailable`。钩子和 `tools/pre-execute` 决定一次调用是否需要询问。
应答者通过 [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md) 描述的桥精确 agent 归属检查进行路由,实现了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。
#### 审计,以及模型看到什么
`approval/asked``approval/decided` 是持久的仅日志事件;模型只看到从结果派生出的普通工具结果。成功完成时,每个 `asked` 都提交一个 `decided`,包括取消和被容纳的应答者失败。空闲时的请求不追加任何事件;提交前失败会拒绝,而第二次追加失败可能留下一个已经提交但未匹配的 `asked`
#### 实体与依赖
`dsh-user-approval` 依赖 Cordis以及会话、agent 和带 brand 的调用契约;`dsh-tools``dsh-acp` 消费它。沙箱执行器保持独立,因为升级请求归 `dsh-tool-bash` 所有。固定的派发与审计服务仍是一个包;可替换的应答者留在各自的通道所有者中。静态能力授权和 `subagent-acp` 子侧权限应答仍是独立关注点。
### 测试
单元测试固定结果、先到先得的委派、错误容纳、取消、作用域路由、审计配对、不可绕过的 `'never'` 策略、工具拒绝原因,以及通过真实脚本化桥实现的 ACP 归属/结果映射。
快照记录通过 `session/request_permission` 批准和拒绝沙箱升级,以及 `'never'` 提示词与策略切换通知。没有脚本化应答的权限提示会取消并失败关闭。
## 延后
- **`allow_always` 授权存储**:兑现持久授权意味着设计存储、作用域标识(调用?路径?前缀?会话?时间窗口?)和撤销;在设计完成之前,只展示一次性选项([沙箱 Agent Note](2026-07-06-sandbox.md) § Escalation 记录了开放的作用域问题)。
- **通过组合应答者录制由钩子驱动的 `ask`**:人类提示协议格式已通过沙箱示例的升级分支录制。钩子矩阵中的 `hook-cc-pretool-ask` 固定无 ApprovalService 时的后备拒绝,而钩子生产者与应答者的组合仍留在单元测试层。
- **将子 agent 的审批路由到父会话**`subagent-acp` 的子侧自动应答自己的 `permission` 请求;将其呈现给父会话的编辑器是独立的设计。
## 曾考虑的替代方案
- **单一注册提供方而非 waterfall 监听器**:否决。`registerProvider()` 接口迫使所有组合问题——允许列表预过滤、外部钩子决策者、脚本化测试应答、人类前面的策略门禁——都塞进一个提供方实现。waterfall 从运行时已有的机制中获得组合能力、缺失时失败关闭和 HMR热模块替换 dispose资源释放seam 的 JSDoc 以约定固定单决策槽语义,而非发明一个提供方注册表。
- **在 ACP 桥中内联 `tools/pre-execute` 权限门禁**:否决。对桥拥有的每次调用都弹出提示,会将请求策略硬编码进 UI 插件,无法服务第二个发起方(沙箱升级发生在执行开始之后,没有 pre-execute 时刻),且钩子产生的 `ask` 决策没有共享机制。
- **通用用户交互 seam`ctx.userInteraction`**:否决作为审批机制。二者骨架相似(按 agent 路由、阻塞等待人类、处理缺失),但审批的契约在每个关键维度上都更窄:封闭的结果词汇而非自由文本、附着在工具调用上的协议原生提示而非通用表单、强制的缺失时失败关闭、以及审计事件。因此审批不走已交付的 `packages/ui/user-interaction` / `ask_user_question` 引出路径——引出表单不是权限提示,自由文本应答不是封闭结果;如果二者将来趋同,共享提供方管道仍然开放。
- **`dsh-tools` 中的静态可选注入**否决。vendor 的 Cordis `Inject` 类型没有 optional 标志——对象形式将服务名映射到拦截配置,声明的 inject 会阻塞 fiber。`ctx.get('approval')` 是文档化的机会性消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测),按调用读取存在性,跨 HMR 正确降级,无需额外机制。
- **能力 seam 的三包拆分**:否决。接口/实现/消费方适合实现可替换的 seambash-local vs bash-sandbox。此处服务体是固定机制可变部分是留在各自通道拥有者插件中的监听器——拆分只会制造一个空的实现包「不要预防性拆分」
- **现在就提供 `allow_always`**:否决。协议能表达它,但兑现它意味着设计授权存储、作用域标识和撤销(§ 延后)。展示 harness 无法兑现的选项只会制造注定失败的授权。
## 后果
实现后的契约由「测试」一节所列套件固定:
- `allowed-once` 派发一次操作;其他所有结果都以不同原因拒绝,而 `'never'` 会在提示前拒绝。
- 缺失、外部、无 agent、抛异常、无效或断开连接的应答路径都会失败关闭。
- 成功的请求按精确 agent 归属路由,并追加一对可回放、对模型不可见的审计事件;空闲时和提交前失败的请求会拒绝。
- ACP 归属把提示限制在其会话内,而没有该服务的部署不产生提示或审计事件。
代价与已接受的局限:
- **两个急于决策的应答者竞争同一槽位。** 兄弟插件的监听器顺序不确定seam 无法仲裁竞争的终端应答者。通过约定缓解(每个部署一个终端应答者;仅对「先决策或委派」门禁使用 `prepend`),而非事件总线不具备的优先级机制。
- **生产环境验证依赖单一组合。** `ask` 有两个生产者家族——钩子桥通过 `tools/pre-execute`沙箱升级通过自己的门禁——协议格式录制在沙箱示例的快照套件中因此在更多部署组合它之前seam 的真实覆盖面就是这一种组合。
- **归属以 `Agent` 对象标识为键。** 应答者先在 `agent.session.id` 处解析正向会话映射记录,再要求该记录拥有精确的 agent 对象;当前所有路径在 loop 和各 seam 之间传递同一对象,但未来如果某个边界克隆或代理了 agent桥会委派并失败关闭——安全但静默无 UI——届时需要另一种归属契约。
## FAQ
- **在完全没有应答者的部署中headless、CI会发生什么** 每次 ask 穿过空的 waterfall 降级为 `unavailable`工具调用以「no approval channel is available」原因被拒绝。失败关闭是零监听器的默认行为不是配置。
- **授权能持久化吗——「始终允许」?** 不能。`allowed-once` 仅授权单次被询问的操作,服务在请求之间不存储任何内容;`allow_always` 在授权存储设计完成之前刻意不展示(§ 延后)。
- **模型看到审批的什么?** 只看到发起方从结果派生的工具结果——审计对永远不进入 transcript文本记录。三种非授权原因各不相同模型可以区分人类说「不」、提示被关闭、通道缺失。
- **谁决定一次调用是否需要 ask** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。
- **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。
- **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。
- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父会话的编辑器已延后(§ 延后)。
- **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次成功的自动拒绝都会记录审计对。
- **热重载或 UI 插件在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。
- **用户在哪里看到自己在批准什么?** 在工具调用本身:提示通过 `callId` 附着在已流式输出的调用上(包含参数),并添加发起方的人类可读 `reason`;请求本身不携带参数副本。
## 先例
本设计复用或对照的仓库内先例:
- `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)——本设计实现的每会话权限归属阻塞项。
- 机会性 `ctx.get()` 消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测)——`dsh-tools` 消费该 seam 而不阻塞其 fiber 的方式。

View File

@@ -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-explicit-tool-order.md: bd6d0a04aa470ca33e618957ae1f08c1ef15fcfe
2026-07-06-explicit-tool-order.zh.md: 5cdecc0e59ff00b6dce7134819f8230072d084cb

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-06-explicit-tool-order.zh.md)
## Problem
Model-facing tool order followed plugin registration order, which depends on concurrent module loading for otherwise independent plugins. That race produced different request headers in CI and snapshot recordings. Because order affects request bytes, caching, and the durable header, it needs an explicit deterministic policy.

View File

@@ -0,0 +1,51 @@
# Agent Note: 显式的模型侧工具顺序
Status: implemented
[English](2026-07-06-explicit-tool-order.md) | 中文
## 问题
模型侧的工具顺序此前跟随插件注册顺序,而注册顺序取决于相互独立的插件的并发模块加载。这种竞态在 CI 和快照录制中产生了不同的请求头。由于顺序影响请求字节、缓存和持久化的 header因此需要一个显式的确定性策略。
## 决策
系统提示词的组装逻辑拥有模型侧工具顺序的权威定义,正如它已经拥有 section 顺序的权威定义一样。`dsh-system-prompt` 上的 `toolOrder?: string[]` 是可选的显式策略:
- 列表中已注册的工具按列表位置排列。
- 列表中的名称没有对应的已注册工具,属于配置错误。形状错误(缺少 rest 条目或名称重复)在服务构造器中快速失败;未注册的名称则在每次 `assemble()` 时拒绝——这是已注册工具集存在并可供检查的最早时刻工具插件在服务构造之后才注册也是唯一的通用时刻注册随时可能变化Cordis 没有「所有插件已加载」事件)。在已交付的 agent loop智能体循环第一个轮次在发出任何模型请求之前就会失败——确切的影响范围见下文「后果」。
- 已注册但不在列表中的工具,插入到 `'<unlisted-tools>'` rest 条目(`TOOL_ORDER_REST`)的位置,与其他未列出的工具按名称字典序排列。
- 任何已收集的工具不得使用 `TOOL_ORDER_REST` 作为其 `ToolSchema.name`;组装逻辑在排序之前就会拒绝这个保留名称。
- 列表必须恰好包含一个 rest 条目,且不得有重复名称。
-`toolOrder` 未设置时权威顺序为纯字典序code-unit 比较,与 locale 无关),因此无需配置即可保证确定性。
`assemble()``system-prompt/assemble` waterfall瀑布式事件之前对提供方工具进行规范化排序从源头消除注册顺序的差异。waterfall 从这个确定性列表开始;不变的顺序随后流入请求头、冻结的请求和重建检查,无需 loop 特有的排序逻辑。
范围刻意收窄:本 Agent Note 修复的是注册顺序竞态,而非插件行为。`system-prompt/assemble` 的监听器仍然可以添加、移除或重排工具——正如它可以在 section 排序之后编辑 section——并对自身输出的确定性负责waterfall 契约已经要求监听器是确定性的(可重建性不变式会捕获在构建与回放之间行为不一致的监听器)。
配置传递沿用 `persona` 的先例,`toolOrder` 与之并列TUI、Headless 和 ACP 应用配置接受该键,并通过 `dsh-agent-spine-demo`(其 schema 是各所有者 schema 的交集)转发给 `SystemPrompt` 子服务。有一个 schemastery 细节至关重要schemastery 数组默认为 `[]`,但省略的 `toolOrder` 必须保持 ABSENT= 字典序),而不是变成一个显式配置的空列表(无效——缺少 rest 条目),因此链路上每个 schema 都将默认值强制为 `undefined`
## 曾考虑的替代方案
- **注册顺序(现状)**:并发导入竞态,依赖宿主环境(上述 CI 抖动),评审中不可见。
- **插件依赖图的线性化**:该关系是偏序的,独立的工具插件不可比较;抖动发生时偏序已完全满足。
- **每个插件在其工具贡献上标注 `weight`**将顺序分散到各插件中仍需一个无人拥有的全局编号约定section 的 `order` 分段已经展示了这种协调成本需要手工承担)。
- **在 `ToolRegistry.schemas()` 中排序(注册表层)**:同样确定,但注册表是一个成员存储,被组装之外的多方消费;排序是提示词组合的关注点,而组装逻辑已经拥有 section 的组合策略。
- **在 `LlmService` 上加配置 + `orderTools()` 方法,由 loop 在记录 header 前调用**:可行,但仅为在远处应用一个策略就增加了一个公开服务方法和一处 loop 改动;每个未来的请求组合者都必须记得调用。在列表诞生处进行规范化使得无序列表不可表示,且零新增接口。
- **在 `llm.stream()` 内部规范化**:在 header 事件已记录之后才运行(抖动仍然存在),且需要重建深度冻结的信封,静默地解除了重建不变式。
- **穷举列表(无 rest 条目)**:每个新加载的工具插件都会导致启动失败;强制的 rest 条目使未列出的工具保持确定性,且其位置是显式的。
- **启动时校验(由 `dsh-app-boot``loader.await()` 之后调用 `SystemPrompt.assertToolOrderSatisfied()`**:能将错误配置变为启动时死亡而非首轮次失败,但代价是一个公开服务方法加上通用启动胶水对单个服务的结构耦合,且无法替代组装时检查(嵌入式调用者从不运行 app boot注册在 boot 之后仍会变化。也没有现成事件可以承载该检查Cordis v4 没有 ready 类事件,`loader/entry-init`/`internal/status` 在加载中途触发(与工具注册存在竞态——正是本 Agent Note 要消除的熵源),而 agent 生命周期事件不会早于组装。在 `assemble()` 设置单一执行点被判定值得接受较晚的失败时刻。
## 后果
- 每个由注册表构建的组装在任何宿主上都以确定性工具顺序开始;在没有专家监听器刻意改变的情况下,每个 `request/header` 事件和模型请求都继承该顺序。CI 与本地之间的注册顺序翻转从结构上被消除,默认为字典序。
- 初始 `PromptAssembly.tools` 是权威的,因此 waterfall 监听器从模型侧顺序开始;提供方注册顺序在该协作 seam 之前无处可观测。
- 快照套件中唯一固定请求头的 fixture`text-turn`)携带新的权威工具顺序;按照固定请求头设计,其他 ACP 快照仍将大块 header 清洗为 `{{system}}`/`{{tools}}`
- 步骤之间的纯工具重排与其他 header 变更一样记录:一份原因是 `'change'` 的完整 `request/header` 快照。稳定的权威顺序会防止注册时序在普通路径上制造这类变化。
- `toolOrder` 键沿 app → `agent-core``SystemPrompt` 的转发链传递,因此部署时将其放在 app 配置中 `persona` 旁边即可;`dsh-llm` 和 agent loop 无需改动。
- `toolOrder` 中拼错或未加载的工具名称在提示词组装时使轮次失败而非启动时loop 在轮次内部组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因平衡关闭并携带错误消息,`agent/error` 镜像该消息,不打开步骤,不记录 `request/header`不向适配器发出请求agent 回到空闲状态。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(符合仓库规则:显式配置引用不得被静默忽略——执行点是组装,因为不存在更早的通用时刻)。
- 工具提供方返回保留的 rest 条目名称时,其提示词组装失败形态与未知的已列名称相同。这防止哨兵值变成一个歧义的真实工具,并保持「从不丢弃工具」的排序契约。
## 测试
系统提示词测试覆盖:字典序默认顺序、列表/rest 位置、提供方顺序无关性、共享名称、无效列表、未知或保留名称、waterfall 前的权威列表以及监听器添加的工具不被重新排序的规则。Loop 测试固定:跨注册排列的已记录与已分发顺序一致、通过 agent-core 和两个 app 的转发、深度冻结的请求,以及在配置了未知名称时的平衡轮次失败(无步骤、无 header、无适配器调用。快照回放仅在固定的 `text-turn` header 中保留完整的权威列表;其他 fixture测试前置数据继续使用 `{{tools}}`

View File

@@ -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-sandbox.md: c1307a7201ed1bc331a86d4ffee002d69fd1d5e5
2026-07-06-sandbox.zh.md: 4e3d4951f71a602bc78551c90dda04317d0f8284

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-06-sandbox.zh.md)
## Problem
A coding agent needs this product path: bash subprocesses — and the hook commands that ride them — execute under a restricted file sandbox by default; if and only if the sandbox actually denies an operation, the model may request one user approval for that same operation and, once granted, retry it once with wider permissions. An every-tool boundary is deliberately NOT the claim: fs/web/todo execute in-process where an `execve` wrapper is meaningless (§ In-process tools), and the cross-family boundary is staged follow-up work (§ Deferred phases). Without a shared vocabulary, every tool reinvents approval fields, denial parsing, retry matching, and permission-state hints.

View File

@@ -0,0 +1,207 @@
# Agent Note: 子进程沙箱——约束 seam、原生 runner、升级机制与按会话模式
Status: implemented
[English](2026-07-06-sandbox.md) | 中文
## 问题
一个编码 agent 需要如下产品路径bash 子进程以及依附其上的钩子命令默认在受限的文件沙箱下执行当且仅当沙箱实际拒绝了某个操作时模型可以为同一操作请求一次用户批准获批后以更宽的权限重试一次。本设计刻意不声称覆盖所有工具fs/web/todo 在进程内执行,`execve` 包装对它们毫无意义(§ 进程内工具);跨工具族的统一边界属于分阶段后续工作(§ 延迟阶段)。如果没有共享词汇,每个工具都会各自重新发明批准字段、拒绝解析、重试匹配和权限状态提示。
harness 是一个 SDK因此约束必须是开发者可组合的能力是否启用沙箱、每个平台使用哪个后端都应作为一等条目写在叶子 `cordis.yml` 中,而非藏在某个执行器的私有机制里。而首选 runner `bwrap` 恰恰在沙箱最重要的主机上不可用(精简容器、禁用了非特权 userns、LSM 拒绝 `mount`),因此备选 runner 必须随 SDK 一起交付,而不能假设主机已有。
仅有约束还留下两个缺口。拒绝后没有升级路径就是死路:模型只能放弃,这会迫使运维人员全局配置 `workspace-write``danger-full-access`,从而使沙箱形同虚设。而模型可见的旋钮(沙箱模式、批准策略)在 agent 生命周期内会变化——ACP 用户切换按会话设置、运维人员在进程停止期间编辑 `cordis.yml`——模型绝不能基于过时的信念行动每次请求时的实际状态是什么、agent 存活期间发生了什么变化、无人看管时又发生了什么变化,都需要有明确答案。
## 决策
一个 seam、一条按平台的本地后端链、一个消费方加上两个上层杠杆按调用的升级路径与按会话的运行时模式。以下所有内容均从叶子 `cordis.yml` 组合而来;不触及 `agent-loop`。跨工具族 fs 强制与按会话工作区根目录已经作为后续设计落到同一策略载体上;剩余阶段——`subagent-acp` 消费方、更多环境与 Windows 链——仍列在 § 延迟阶段。
### 部署方式
四条 `cordis.yml` 条目即可将一个无约束的编码 agent 转变为沙箱产品路径;[`examples/acp-agent`](../../../../examples/acp-agent/README.md) 默认使用此组合:
```yaml
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local' # the per-platform runner provider (ctx.sandbox)
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox' # the confined executor, replacing dsh-bash-local behind ctx.bash
config:
mode: workspace-write # the deployment default every session starts from
workspaceRoot: !!js process.cwd() # the boundary workspace-write may write under
- id: approval
name: '@deepseek-ai/dsh-user-approval' # the escalation gate's channel (the approval Agent Note)
config:
policy: ask
- id: permission
name: '@deepseek-ai/dsh-permission' # one product-facing select over both mechanism knobs
```
这一替换对 `ctx.bash` 的所有消费方透明bash 工具、钩子命令和后台任务照常运行,通过提供方返回的包装 argv spawn。删除 `sandbox``permission` 条目、将 `bash` 替换为 `@deepseek-ai/dsh-bash-local` 即为退出——执行恢复为无约束,升级字段从工具 schema 中消失,因为它们是基于已挂载执行器的能力门控,而非基于配置。仅省略 `approval` 则保留约束但以自身错误文本关闭每次升级;`permission` 还要求 approval seam 和约束执行器同时存在,因此部分组合的 preset 层在加载时即大声失败。
配置错误大声失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段(命令 spawn 之前)抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner seam。
被拒绝的文件操作返回 `[sandbox: file access denied under <mode> mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions``justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to "<mode>"`,且不允许再次请求。提示词不声明沙箱模式,以避免基于常驻标签的预防性拒绝。当 `dsh-permission` 被组合时ACP 暴露一个 `Permissions` 选择器,其 preset 同时写入两个旋钮事件;不匹配的旋钮组合显示为仅可切换离开的 `custom`。只有切换到确定性的 `'never'` 批准策略才会在提示词中声明并叙述。
### 设计细节
#### 范围界定
OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还将适用于 ACP subagent 子进程。文件系统、web 和其他工具在进程内执行,需要在各自的 seam 层面实施策略argv 包装无法约束一个闭包了 `ctx` 的函数。既有的 bash request/spec 拆分承载按调用的覆盖,而 `tools/pre-execute` 和 approval seam 负责人的决策。
#### seam`ctx.sandbox`
`dsh-sandbox` 拥有词汇和 `SandboxProvider` 契约:`confine(argv, policy)` 返回调用方应当 spawn 的替代 argv经过包装使进程及其所有子进程在约束下运行加上所选后端达到的 `enforcement` 完整度、其拒绝方言(`denialSignatures`,该后端内核在拒绝文件操作时打印到 stderr 的子串)、以及其 runner 失败方言(`runnerFailureSignatures`runner 本身失败——因而命令从未运行——时的自我标识方式);没有可用后端时抛出失败关闭的 `SANDBOX_UNAVAILABLE` 错误,绝不静默放行。词汇:`SandboxMode``read-only` / `workspace-write` / `danger-full-access`,仅限文件操作——不声称覆盖网络和进程可见性)、`SandboxEnforcement``full` / `partial`)、`SandboxExecutionPolicy`(每次能力调用的完整 mode + workspace root以及 `SandboxPolicy`(提供给约束后端的子集)。
策略随每次调用而非提供方携带两个消费方可以在同一时刻以不同策略约束bash 在 `read-only` 下运行,而一个受约束的子 agent 保持其状态目录可写),且经批准的升级重试是一次带有更宽策略的新调用——在配置固定的提供方模式下无法表达。
该 seam 仅约束与宿主机共享文件系统和内核的子进程。容器、microVM 和远程执行器不是此 seam 的后端——它们以环境一致的组替换整个能力实现(`ctx.bash``ctx.fs`),因为一个 bash 在容器中运行而 fs 工具写主机的 agent 生活在两个割裂的世界中。
留待需要时再决定:网络限制是作为独立的 `network_mode` 到来,还是在某个 runner 同时强制两者后合并进 `sandbox_mode`;以及 `SandboxPolicy` 是现在就增加额外的可写根授权launcher 已支持 `--rw <path>`),还是等到升级机制需要时再加。
#### 本地后端与随附 launcher
`dsh-sandbox-local` 在提供方生命周期内选择一个平台 runner 并缓存结论。Linux 功能性探测 `bwrap` 然后 LandlockmacOS 使用 Seatbelt。不支持的平台和不可用的 runner 失败关闭。每次包装携带后端特定的拒绝签名和 runner 失败签名,以便 `dsh-bash-sandbox` 区分被拒绝的文件操作与损坏的沙箱。`runnerCommand` 作为运维人员对 bwrap 形状 runner 的断言跳过选择,但缺失或不可执行的命令仍被归类为沙箱失败,绝不无约束地运行负载。
launcher 是一个约 300 行的 C 程序(纯 C11直接使用 Landlock UAPI——除静态链接的 musl 外无其他库,因此审计面仅为该文件加内核的稳定 syscall 契约):`--ro <path>` / `--rw <path>` 授权,`--`,被包装的 argv它在自身上安装规则集并 `exec`(规则集跨 `execve` 继承,且它在限制前设置 `no_new_privs``--probe` 在一个短生命周期子进程中强制最大规则集,仅当内核确实强制时才以 0 退出launcher 失败以 125 退出且不 exec。
Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harness 消费方同仓。独立的 [`node-addon-landlock-run`](https://github.com/deepseek-harness/node-addon-landlock-run) 仓库是用于打包并发布 npm 包族的发布镜像;导出流程归 `native/README.md` 所有。平台二进制由 npm 选择,入口包拥有路径解析、探测和 CLI flag而 harness 将沙箱模式映射为授权。将入口点与其二进制一起版本化,使探测解析和启动语法保持对齐。
后端 profile 共享模式契约但在必要的主机授权上有所不同。Landlock 和 Seatbelt 在 read-only 模式下仅允许 `/dev/null`workspace-write 还允许各自所需的主机临时目录根。每次包装携带后端特定的拒绝签名。Landlock 在较旧的 ABI 无法管控所有操作时报告 partial enforcement而成功的 bwrap 和 Seatbelt profile 报告 full enforcement。
#### bash 消费方
`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,并把即将 spawn 的确切 `['bash', '-c', command]` argv 交给 `ctx.sandbox`。拒绝是与其他结果正交的事实,依据当前 runner 的 stderr 方言保守分类。Runner 失败优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`;结算后的 `BashProcess` 会盖章 `sandbox.runnerFailed`bash 生产者再通过通用 `task_output` 渲染它。
模型看到的仅是结果事实:静态工具描述解释拒绝标记(`[sandbox: file access denied under <mode> mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试;当升级字段被公布时,被拒绝的结果还额外携带升级提示本身,使被认可的同轮次重试在决策点被提示,而非依赖模型回忆描述(§ 升级机制)。没有提示词段落声明沙箱模式(§ 按会话模式)。
#### 升级机制:拒绝后一次经批准的更宽重试
`BashExecRequest.sandboxPolicy` 是可选的完整按调用输入;解析后的 spec 使该字段显式。`BashExecutor.sandboxMode` 仍是公布已挂载执行器能否兑现该策略的能力事实因此只有约束组合才暴露升级。seam 接受任何显式策略;工具拥有会话解析和「仅更宽」的升级规则。非沙箱执行器诚实地保持无约束。
`ctx.sandboxPolicy.resolve()` 在执行器运行前盖章完整执行策略——显式升级模式 > 会话覆盖 > 配置默认值,且 `SessionHeader.cwd` > 配置的后备根目录。`SandboxBashExecutor.resolve()` 在 spec 上保留该策略,或为直接的无 agent 调用方提供部署后备值,使 `run()`/`start()` 永不读取可变会话状态。每进程包装事实以返回的 `BashProcess` 为键;`onProcessDone()``done` 结算前分类 stderr 并给该句柄盖章,因此重叠进程各自保留自己的模式和 runner 方言。
当约束执行器被挂载时,`bash` 公布配对的 `sandbox_permissions``justification` 字段。schema 暴露完整的封闭升级词汇,因为有效模式是按会话的;执行拒绝任何不严格宽于该调用有效模式的目标。批准在执行之前解析。`allowed-once` 仅将授权模式盖章到该请求上,而 `rejected``cancelled``unavailable`、缺失的 approval 服务或缺失的 agent 都以各自不同的结果文本失败关闭。授权不持久化。
升级是对被拒绝命令的同轮次重试,使用最窄的足够 `sandbox_permissions` 和一个 `justification`;批准提示是同意步骤。它必须基于实际的拒绝,除非会话已观察到相同的被拒绝访问;禁用或被拒绝的批准终结该命令。重试、批准决策和结果使用既有的工具和批准事件。`dsh-tool-bash` 拥有请求动作,因为执行器 seam 既没有 agent 也没有用户交互所需的 call id。
仍未决定:持久授权超出沙箱模式之外的作用域标识是什么——确切调用、路径、命令前缀、会话或时间窗口——这是公布 `allow_always` 选项之前必须回答的问题。
#### 按会话模式:会话日志即存储
```
effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default
```
默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件。重启免疫(恢复会话时回放其日志,覆盖自然恢复,无需追赶机制)和多会话隔离(一个编辑器标签页的 `workspace-write` 不会干扰另一个的 `read-only`)都是构造性的自然结果,且不存在任何外部配置存储。
**每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`
```ts
interface SessionEventMap {
'sandbox/mode': { mode: 'read-only' | 'workspace-write' | 'danger-full-access' }
'approval/policy': { policy: 'ask' | 'never' }
}
```
每个拥有者导出相同的三件套:事件声明、纯 fold`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)`——一个 `findLast`,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。无共享拥有者服务、无通用 facts map、无注册表第三个旋钮只需将约 40 行模式复制到自己的包中。执行在两侧都遵循 fold——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.md) 同一模式的另一侧。
沙箱模式不在提示词中叙述;拒绝结果在需要时报告模式,避免基于常驻标签的预防性拒绝。批准策略不同:只有 `'never'` 被声明,因为自动拒绝在行为上与用户的「不」无法区分。策略变更通知被合并,由下一个步骤前检查点递送,重启后有基于日志的回退。通知来源从事件位置推断:最后一个 request header 之后的旋钮事件是用户驱动的;未记录的漂移是运维人员或配置驱动的。
**编辑器界面**是协议原生的[会话配置选项](https://agentclientprotocol.com/protocol/session-config-options)——该规范对 session modes 的替代(计划在 ACP v2 中移除),已有 SDK 类型。当 `ctx.permission` 被组合时bridge 在 `session/new``session/load` 中公布一个 `permission` 选择器category `mode`);其选项是部署的 preset 表,其 `currentValue``PermissionService.current()` 对会话日志加组合默认值的结果。随附的 `workspace-write``danger-full-access` preset 各自捆绑一个沙箱模式与一个批准策略,并写入两个领域 setterpreset 表之外的旋钮组合报告为仅可切换离开的 `custom``session/set_config_option` 通过权限服务验证并切换,然后返回完整的刷新状态(规范契约)。
**轮次封闭是提交边界。** 开放轮次中的切换立即追加。空闲切换保持在 bridge 记录上待定在下一次提示词提交时、assembly 或执行之前追加到开放轮次中;每个旋钮以最后写入为准。开放性来自日志边界而非 `agent.status`setter 不从 `session/event` 监听器内追加,因为那会重排后续观察者。锚定之前,响应叠加待定值。崩溃丢弃它,重新加载返回持久 fold。
#### 进程内工具
fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层面的策略。fs seam 现在通过沙箱提供方强制共享模式词汇(`dsh-fs-sandbox` 按模式限制 write/edit见[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md)),因此 `read-only`/`workspace-write` 对文件系统工具也是真实边界,而非仅限 bash 的近似。web/todo 仍不受限制web 的唯一效果是网络,不在文件效果模式词汇内)。没有通用的按工具沙箱运行时:主机中介的工具仅通过返回主机验证的声明式效果来离开进程,那是一次重写而非包装——后续设计选择了一个共享策略归属 `ctx.sandboxPolicy`,由各 seam 强制,而不是统一包装器。
### 测试
- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传、叙述器合并、ACP 公布和验证、轮次封闭的配置写入。
- **Keyless 真实 runner** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。真实 ACP 组合固定权限切换并拒绝未知 preset。CI 拒绝静默全跳过。
- **With-key** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。
- **快照:** 固定权限 config-option 协议格式wire format、preset 和旋钮事件、提示词 delta 和通知、以及两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定一次成功的 workspace-write 变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture测试前置数据保持平台无关策略场景显式切换。
## 延迟阶段
每个阶段在被拾起时获得完整设计,对照当时的代码验证,并在其涉及的层级带上单元测试、真实 API e2e 和快照覆盖率落地。
- **第二个消费方**——`subagent-acp` 可选地约束子 agent按调用策略默认无约束——子 agent 必须写入自己的持久化)。
- **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。
- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。
## 曾考虑的替代方案
- **命令字符串启发式预检**:否决。无法理解展开/子进程/符号链接;严格尝试(运行它,让内核决定)是唯一可信的拒绝信号。
- **即使平台仅有一个后端也功能性探测**否决。探测用于在候选者之间仲裁只有一个时无需决策且探测开销对每个会话的首次约束命令征税对未来重量级后端而言代价过高。runner 自身执行时的失败关闭拒绝加 `runnerFailureSignatures` 分类承载了安全属性。
- **提交构建好的 launcher 二进制**否决。diff 中的二进制不可审查且膨胀历史;经审查的源码 + 原生 CI 构建 + launcher 仓库的字节固定发布演练使二进制远离所有代码树。
- **安装时编译 launcher**:否决。将 C 工具链强加给每个消费方;仅在碰巧有编译器时才存在的备选不是备选。
- **从一个构建器交叉编译两种架构**:否决。仅为重建两个约 70 KB 的二进制就需要携带一个固定的交叉工具链rustup targets、zig 或容器镜像);每架构的原生 runner 已存在,各自构建自己的平台包(`node-addon-require-builtin` 模式launcher 仓库自己的流水线)。
- **无备选bwrap 或失败关闭)**:否决。将失败集中在沙箱最重要的主机上,最终因放弃而降级到 `danger-full-access`
- **将机制保留在 `dsh-bash-sandbox` 内部**:否决。阻塞既有的第二个消费方,使未来阶段从一个 bash 插件的配置中读取模式,且无法表达升级。
- **提供方上的配置固定模式**:否决。每进程一个模式;无法服务具有不同策略的并发消费方,也无法表达一次性放宽重试。
- **一个接口同时覆盖容器/VM**:否决。`confine(argv)` 预设共享文件系统;环境隔离是作为一致组部署的能力兄弟后端。
- **通用 ToolRuntime 包装任何工具**:否决。对进程内工具(闭包了 `ctx`)机械上不成立;声明式效果重写对 fs/web/todo 而言不合理。
- **在执行器内部(`dsh-bash-sandbox`)请求批准**:否决。没有可路由的 `agent`,没有可附加提示词的 `callId`;添加它们会让传输 seam 了解会话和 UI——工具层持有两者并拥有面向模型的词汇。
- **同一工具调用内自动重试**:否决。日志无法重建的隐藏重入:一个 `tool/call` 会产生两次具有不同策略的执行——重试是一次新的带有自身参数和结果事实的已记录调用。
- **无条件公布升级字段**:否决。在 `dsh-bash-local` 下它们是死杠杆——公布 harness 无法兑现的选项会制造注定失败的授权;能力门控仅需注册时一次读取。
- **默认值相对的升级阶梯(仅公布比执行器注册时默认值更宽的模式)**:否决。按会话覆盖使默认值成为错误的基线——切换到比默认值更窄的会话恰恰失去它需要的杠杆,而在 `danger-full-access` 默认值下字段完全消失,同时一个被覆盖为 `read-only` 的会话仍处于约束中却没有升级路径。枚举固定封闭的目标词汇;严格放宽是针对会话有效模式的按调用执行检查。
- **按会话动态工具 schema**否决。schema 设计上是注册表全局的(一套 assembly 词汇、固定 header 快照契约),按会话重新注册只能买到执行时严格放宽检查已保证的东西,代价是按会话的 schema 表面和每次切换的 header 变动。
- **将重试硬匹配到先前的拒绝**:否决。命令字符串同一性脆弱(引号、`workdir`、env 前缀、作为失败阶段重试的管道)——要么误拒诚实的重试,要么被轻易满足;真正的边界是人看到命令 + 理由。仅在 `allow_always` 授权存储需要机器可检查的范围时才重新考虑。
- **通用 `env/state` facts map 加拥有者服务**否决。approval 和沙箱独立组合,因此任何一方的状态都不应拖入第三个包;单键 fold 各自是一个 `findLast`,拥有者服务自然消解;没有跨旋钮的不变式,因此原子多键补丁无收益。
- **通过 `agent/user-message` + 总线事件叙述**:否决。它预设了一个不存在的轮次入口 seam真正的 seam 是 `agent/prompt-submit`),而步骤前检查点的位置使一个监听器能够同时服务合并的轮次入口通知和轮中即时性约束。
- **提示词中常驻声明沙箱模式(+ 切换叙述器)**:先交付后移除,基于实际证据:当每个请求中都有 `Bash commands run under the "read-only" file sandbox.` 时,模型拒绝尝试被拒绝后可升级的工作(首次手动会话中十二个轮次有五个以零工具调用结束),将沙箱变成了软锁定。拒绝标记在需要时命名模式,升级字段承载恢复路径;批准旋钮保留其声明,因为自动拒绝在行为上与人的「不」无法区分。
- **用专门的簿记事件追踪「上次告知」**:否决。`request/header` fold 已记录模型看到的确切提示词;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们本身即为存储时才需要。
- **ACP session modes 而非 config options**否决。preset 已经是一个部署定义的 config-option 选择器,且 modes 计划在 ACP v2 中移除。
## 后果
已交付并固定的内容——测试中的各层级分别保障:
- 被拒绝的命令以 `sandbox_permissions` + `justification` 重试时,通过组合的应答器链提示用户;授权使该次调用在更宽模式下运行(结果事实如此报告),而其他所有调用保持各自的有效模式;每种非授权结果产生各自不同的错误文本且不执行任何内容。
- 升级字段恰好在已挂载的执行器约束时存在;不严格宽于调用有效模式的请求以自身文本失败关闭且不提示任何人;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。
- 系统提示词从不声明沙箱模式(批准 `'never'` 策略是唯一被声明的旋钮且整个交互——header、旋钮事件、通知、批准、结果——仅从会话日志即可重建除两个旋钮事件外无额外事件类型。
- N 次空闲切换每个旋钮最多产生一个锚定事件(净零序列不锚定任何事件——客户端回显当前选择的无操作推送不记录任何内容);批准策略切换最多以一条合并通知叙述;轮中沙箱切换由下一次调用的盖章兑现。
- 恢复的会话的覆盖生效并报告给编辑器,无需特殊处理;进程停止期间变更的默认值在会话的首个新请求前被叙述,归因于运维人员。
- 两个并发会话永远看不到彼此的状态、通知或配置选项。
- 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录bash 和 fs 写入在调用方会话的 cwd 内成功,对其相邻会话的 cwd 则失败。
- `agent-loop` 未被触及——一切搭载 `systemPrompt.section``SessionEventMap` 合并、`agent.inject()``agent/pre-step``agent/prompt-submit` 和 ACP handler 表面。
代价与已接受的限制:
- **单一包装的幻觉被有意放弃。**`tools/pre-execute` 包装加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。
- **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。
- **Windows 没有后端。** 其链槽保留为空——失败关闭,绝不穿透;填充它是延迟阶段。
- **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此未来移除会在执行时作为 runner 失败分类浮现——重新抛出 `SANDBOX_UNAVAILABLE`,命令从未运行;失败关闭,绝不开放。
- **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。
- **launcher 作为注册表依赖到达。** 通过其自身仓库的发布流水线(经审查的 C 源码、原生 CI 构建器、字节固定的发布演练)加上本仓库的版本固定获得信任——真实内核 e2e 测试腿是通过安装字节为行为背书的。
- **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。
- **公布的目标集是静态的,而有效模式是按会话的**schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。
- **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——在平台没有链或所有探测失败时于 `confine()` 阶段,在未探测的唯一 runner 拒绝时于执行阶段(归类为沙箱失败而非命令失败)——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。
- **空闲切换存在于 bridge 内存中,直到下一次提示词提交锚定它。** 该窗口内的崩溃将其回退(在 `session/load` 时报告且永不再提交提示词的会话永不持久化它——已接受loop 拥有的空闲提交轮次留作未来工作(如果持久性成为需求)。
- **批准叙述器的重启基线解析提示词文本。** 封闭的候选句子由写入模块本身拥有,因此措辞变更是同一文件中写入器+解析器的协调编辑header 早于该段落的会话静默采用当前策略而不发通知。
- **批准段落仍是动态提示词表面**`'never'` 切换会破坏该会话的提供方提示词前缀缓存)。已接受:策略切换罕见,且模型基于过时的 `'never'` 行动更糟。沙箱旋钮不再触及提示词。
- **模型可能持有关于沙箱模式的过时信念**(没有任何东西宣布切换)。有意接受:下一次尝试的标记或成功会纠正它,而宣布的观察到的失败模式——预防性拒绝——比一次浪费的重试更糟。
## FAQ
- **一个命令返回了 `[sandbox: file access denied under read-only mode]`——它失败了吗?** 它运行了,内核拒绝了一个文件操作:拒绝是与退出码正交的结果事实。教学禁止绕过它重试;唯一被认可的动作是以升级请求重试同一命令一次。
- **如何区分损坏的沙箱与失败的命令?** Runner 失败在分类中优先于拒绝:匹配包装的 `runnerFailureSignatures` 的失败运行意味着命令从未运行——前台重新抛出结构化的 `SANDBOX_UNAVAILABLE` 并附带 runner 的 stderr 行,后台任务盖章 `sandbox.runnerFailed` 并渲染自己的标记。损坏的沙箱永远不会被读作失败的命令,且命令永远不会无约束运行。
- **在没有后端的平台上会发生什么——今天的 Windows** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。
- **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到注册表安装的 Landlock launcher结论在提供方生命周期内缓存。
- **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作bwrap profile 刻意不 unshare pid没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。
- **哪些工具实际在约束下运行?** 通过 `ctx.bash` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md)bash 通过 OS runner 约束fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制web 的唯一效果是网络,不在文件效果模式词汇内)。
- **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `task_output` 呈现,并且可以作为一次新的精确命令重试的依据。
- **编辑器的模式切换何时生效?** 轮中:立即追加,由下一次调用的盖章兑现。空闲:保持在 bridge 的会话记录上,在下一次 `agent/prompt-submit` 时锚定到其开放轮次中N 次切换合并为最多一个事件(净零则无);锚定前崩溃回退它,`session/load` 报告真实状态。模型不被告知——其下一个命令直接在新模式下运行。
- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从会话日志回放(`effective = fold ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值以与切换相同的方式改变行为(批准策略因被声明,还额外以运维人员/配置归因叙述)。
- **结果上的 `enforcement: 'partial'` 是什么意思?** 所选后端强制其内核 ABI 管控的子集——例如 ABI v3 之前的 Landlock 不管控路径 truncate——并以结构化方式如此声明而非拒绝主机探测的报告行区分各种情况。bwrap 和 Seatbelt profile 构造上管控所有承诺的文件操作,因此始终报告 `full`
## 先例
本设计复制或对比的仓库内先例:
- [能力 seam Agent Note](../architecture/2026-06-13-capability-seams.md)——接口/实现/消费方拆分与「不要过早拆分」的时机规则(第二个消费方满足了该规则)。
- `dsh-bash` 的 request/spec 拆分([bash 词汇目录](../../../../docs/core-data-structures/bash.md))——完整的 `sandboxPolicy` 搭载其按调用载体,以及显式 `resolve()` 默认约定。
- [批准 seam Agent Note](2026-07-06-approval-seam.md)——升级请求通过的通道;其应答器 waterfall瀑布式事件、审计对和单包理由记录在那里。
- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.md)与[轮次封闭不变式](../architecture/2026-06-15-turn-enclosure-invariant.md)——按会话模式 fold 所依赖的日志即存储基础,以及锚定设计遵守的提交边界。
- [拦截 seam Agent Note](2026-06-30-interception-seams.md)——`tools/pre-execute` 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。

View File

@@ -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-07-mcp-client-plugin.md: 90c43383882034954f33b79459fac273e60b9e0e
2026-07-07-mcp-client-plugin.zh.md: a60aa537a03dec5add7e91fd78b91fece4e4a259

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-07-mcp-client-plugin.zh.md)
## Problem
The harness had no way to consume tools from the MCP (Model Context Protocol) ecosystem. MCP is the emerging standard for tool servers — GitHub, filesystem, databases, code search, and hundreds of community servers expose tools via MCP. Users want to point the harness at one or more MCP servers and have their tools appear as native model-facing tools, without writing per-server glue code.

View File

@@ -0,0 +1,214 @@
# Agent Note: MCP 客户端插件——连接外部 MCP 服务器并桥接其工具
Status: implemented
[English](2026-07-07-mcp-client-plugin.md) | 中文
## 问题
harness 此前无法消费 MCPModel Context Protocol生态中的工具。MCP 是工具服务器的新兴标准——GitHub、文件系统、数据库、代码搜索以及数百个社区服务器都通过 MCP 暴露工具。用户希望将 harness 指向一个或多个 MCP 服务器,让其工具以原生的模型可见工具形式出现,而无需为每个服务器编写胶水代码。
`ToolRegistry` 已经接受原始 JSON Schema 工具定义(`dsh-tools` README 中有记录「Raw JSON-Schema tool definitions (from MCP servers) are still accepted by `ToolRegistry.register()` directly」扩展实操手册cookbook也勾勒了预期模式「MCP | one plugin per server: discover tools → `ctx.tools.register()`」)。基础设施已就绪,缺的是桥接插件。
## 决策
### 包
单个包package `@deepseek-ai/dsh-mcp-client`,位于 `packages/mcp/mcp-client/`。不做能力 seam 的三包拆分——可预见范围内不会有第二种 MCP 客户端实现,且约定是「不要预防性拆分」([能力 seam Agent Note](../architecture/2026-06-13-capability-seams.md))。
### SDK
使用官方 [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk)`Client``StdioClientTransport``StreamableHTTPClientTransport`。harness 不自行实现 JSON-RPC与 ACP 委托给 `@agentclientprotocol/sdk` 的做法一致。
### 范围
仅 MCP Client不含 server 端——ACP 已承担「将 harness 暴露为 agent」的角色。仅桥接 **Tools**——Resources 和 Prompts 延后处理(它们需要 harness 侧尚不存在的消费机制,且设计空间较大)。
### 插件形态
命名空间插件(具名导出 `name`/`inject`/`Config`/`apply`,无 `export default`)。`inject: ['tools']`。每个 MCP 服务器对应 `cordis.yml` 中的一个插件实例——同一个包以不同配置加载 N 次,与 `dsh-tool-subagent` 相同。
### 配置
`transport` 字段为判别的扁平联合类型:
```typescript
interface StdioConfig {
transport: 'stdio'
serverName: string // required namespace, ^[A-Za-z0-9_-]{1,32}$
command: string
args?: string[]
env?: Record<string, string>
cwd?: string
toolCallTimeoutMs?: number // default 60_000
}
interface StreamableHttpConfig {
transport: 'streamable-http'
serverName: string // required namespace, ^[A-Za-z0-9_-]{1,32}$
url: string
headers?: Record<string, string>
toolCallTimeoutMs?: number // default 60_000
}
type Config = StdioConfig | StreamableHttpConfig
```
`serverName` 是稳定的本地标识,用于在模型可见名称(见下文)中为该服务器的工具提供命名空间。它有意设计为用户配置,而非远端的 `serverInfo.name`:远端名称是不可信输入、跨部署不唯一(同一服务器的生产和预发布实例报告相同名称)、且可能在服务器升级时变化——这些都不得静默重命名模型可见工具。多个活跃实例使用重复的 `serverName` 属于配置错误:后加载的实例在启动时以可操作的错误消息失败,绝不静默覆盖或跳过。短 `serverName`(如 `gh`)也是缩短公开名称的调节手段。
`cordis.yml` 用法示例:
```yaml
- id: mcp-github
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: github
transport: stdio
command: npx
args: ['-y', '@modelcontextprotocol/server-github']
env:
GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN
- id: mcp-web
name: '@deepseek-ai/dsh-mcp-client'
config:
serverName: web
transport: streamable-http
url: http://localhost:3000/mcp
headers:
Authorization: !!js `Bearer ${process.env.MCP_TOKEN}`
```
模型看到的是 `mcp__github__create_issue``mcp__github__search_code``mcp__web__search`
### 生命周期
启动时从 `cordis.yml` 加载。HMR热模块替换`@cordisjs/plugin-hmr`)提供热替换:编辑 yml 条目触发旧实例的 dispose资源释放断开连接、注销工具并创建新实例连接、发现、注册。目前不提供运行时动态 API。公开名称是 `(serverName, rawName)` 的纯函数,因此保持 `serverName` 不变的 HMR 替换会重建完全相同的模型可见名称——会话历史和权限规则保持有效——而添加或移除不相关的服务器永远不会重命名已有工具。
### 工具发现与注册
每个 MCP 工具有两个名称:
- `rawName`——MCP `Tool.name` 的原始值,仅用于协议通信(`tools/call`)。
- `publicName`——在 `ToolRegistry` 中注册的全局唯一模型可见名称:
mcp__<serverName>__<rawName>
这种按服务器限定的形式是多服务器 agent 客户端的事实标准——所有被调研的终端用户产品都按服务器限定 MCP 工具名([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`、[Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`、[Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces)、[VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260)、[Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35)、[Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140)、[Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441)、[OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120)`mcp__<server>__<tool>` 的拼写方式与 Claude Code 和 Codex 一致。`mcp__` 前缀将 MCP 注册与原生工具的命名空间隔离,并为权限/遥测规则提供稳定的匹配模式(`mcp__*``mcp__github__*`)。
1. 连接时:遍历 `client.listTools()` 的分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`
2. 监听 `notifications/tools/list_changed` → 重新执行同步dispose 上一代、注册新一代)。确定性命名意味着未变化的工具在重新同步后保持原名。
3. 执行器闭包持有 `rawName`;公开名称永远不发送给服务器,也永远不被解析以还原原始名称。
4.`presentCall`/`presentResult`——ACP 桥接的通用卡片兜底负责渲染。
5. 工具在系统提示词中是透明的——除名称本身外不附加「[via MCP]」标注。
### 公开名称规范化
MCP 允许工具名最长 128 字符且可包含 `.`DeepSeek 的函数名契约允许 `[A-Za-z0-9_-]` 且最多 64 字符。公开名称按确定性规则规范化:非法字符替换为 `_`,当替换或截断改变了名称时,追加 `(serverName, rawName)` 标识的 12 位十六进制 SHA-256 hash确保不同的 MCP 标识永远不会坍缩为同一个公开名称:
```typescript
function publicToolName(serverName: string, rawName: string): string {
const joined = `mcp__${serverName}__${rawName}`
const normalized = joined.replace(/[^A-Za-z0-9_-]/g, '_')
if (normalized === joined && normalized.length <= 64) return normalized
const hash = sha256(`${serverName}\0${rawName}`).slice(0, 12)
return `${normalized.slice(0, 64 - 13)}_${hash}`
}
```
### 名称冲突处理
MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names)唯一;跨服务器冲突是常态而非例外(一项[微软研究院调查](https://www.microsoft.com/en-us/research/blog/tool-space-interference-in-the-mcp-era-designing-for-agent-compatibility-at-scale/#namespacing-issues-and-naming-ambiguity)覆盖 1,470 个服务器,发现 775 个冲突的工具名;仅 `search` 就出现在 32 个服务器中,官方 GitHub 服务器发布的是裸名 `create_issue`)。始终启用的命名空间从结构上杜绝冲突,而非在冲突发生时再处理:
- 两个服务器都发布 `search` → 共存为 `mcp__github__search``mcp__web__search`
- 名为 `search` 的原生 harness 工具不受影响。
- 重复的 `serverName` 配置使后加载的实例在启动时失败(见配置一节)。
- 服务器列出重复的工具名属于无效工具列表:同步抛出异常,上一代注册保持不变。
- 替换期间的注册表冲突只可能意味着外部工具占据了该服务器的 `mcp__<serverName>__` 命名空间:部分代注册被回滚(该服务器零工具),并以醒目日志记录错误。
工具永远不会被静默跳过;哪些工具可用永远不取决于插件加载顺序。
### 命名不变式
1. 每个 MCP 工具拥有稳定标识 `(serverName, rawName)`;每个活跃标识恰好对应一个公开名称。
2. 公开名称是确定性的、全局唯一的,且满足 DeepSeek 64 字符 `[A-Za-z0-9_-]` 契约。
3. MCP `tools/call` 始终接收原始的 raw name。
4. 连接、断开或重新同步不相关的服务器永远不会重命名已有工具。
5. 注册顺序永远不决定哪个工具可用。
### 工具执行
为来自同一个 MCP 服务器的所有工具提供统一的 `execute` 处理器:
1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。
2. 映射结果:
- 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(必要原因:`flattenText` 使用 `join('')` 无分隔符,多块会丢失块间边界)。
- `image` 内容块 → 丢弃并 `ctx.logger.warn`harness 没有图片内容块类型;[删除图片 Agent Note](../simplification/2026-07-04-drop-image-content-block.md))。
- `isError: true` → 映射到 harness 的 `isError` 结果路径(`{ content: [...], isError: true }`)。
3. 取消:`exec.signal`(来自 agent loop智能体循环的取消透传给 MCP SDK 的 `callTool`,后者向服务器发送 `$/cancelRequest`
### 子进程环境stdio 传输)
复用 `dsh-subagent-acp``buildChildEnv` + `SENSITIVE_ENV_PATTERN` 清洗逻辑:过滤环境变量(剥离匹配 `/KEY|SECRET|TOKEN/i` 的凭证形变量),然后将 `config.env` 覆盖合并到顶层。显式配置的 env 不受清洗影响。
### 断连 / 崩溃
不自动重连。如果 MCP 服务器进程退出或传输层关闭:
1. effect dispose → 所有已注册工具被注销fiber 作用域的 disposer
2. 后续模型对这些工具的调用 → `ToolNotFoundError``isError: true`
3. 恢复:用户编辑 `cordis.yml`(触发 HMR 重载)或重启 harness。
这与 ACP subagent 模式一致:「崩溃即终态,报告错误,清理资源,不重试。」
## 曾考虑的替代方案
### MCP Server 端(将 harness 工具暴露给外部 MCP 客户端)
延后。ACP 桥接已将 harness 暴露为 agent 服务器。再加一层 MCP server 会以不同协议重复这一功能,而用户的首要需求是消费外部工具,而非暴露自身工具。
### 能力 seam 三包拆分(接口 / 实现 / 消费方)
否决。可预见范围内不会有替代的 MCP 客户端实现——MCP 只有一个协议、一个 SDK。约定是「不要预防性拆分」直到出现第二种实现。
### 指数退避自动重连
v1 否决。引入复杂性(工具已注册但暂时不可用的部分可用状态),且 stdio 进程崩溃通常表明配置问题重试无法修复。HMR 已提供手动恢复路径。如有需要,可在未来作为 `reconnect: boolean` 配置项添加。
### 桥接 Resources 和 Prompts
延后。Resources 需要 harness 侧的机制来决定何时注入内容系统提示词按需模型触发。Prompts 需要 harness 尚不具备的「提示词模板」概念。两者都需要独立设计Tools 是高价值、低风险的起点。
### 原始模型可见工具名加可选 `toolPrefix`
否决。这是最初的提案,基于「大多数 MCP 服务器已在工具名中使用语义前缀(如 `github_create_issue`)」这一前提。该前提不成立:官方 GitHub 服务器发布的是 `create_issue`,参考文件系统服务器发布 `read_file`Sentry 发布 `search_issues`——且上述微软调查表明冲突在生态规模下很常见。冲突时再加前缀(或 warn-and-skip还会使可用工具集取决于插件加载顺序且添加不相关服务器时工具可能被静默重命名——在对话中途使会话历史和权限规则失效。所有被调研的多服务器 agent 产品都不使用裸名。
### 仅服务器命名空间(`github__create_issue`,无 `mcp__` 前缀)
v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 harness 工具分离,也丧失了 MCP 全局策略匹配模式(`mcp__*`)。前缀仅多花 5 个字符;`mcp__<server>__<tool>` 拼写与 Claude Code 和 Codex 一致,最大化模型的熟悉度。如果 ToolRegistry 未来引入源感知命名空间,届时可作为命名策略变更重新考虑去掉字面前缀。
### 从服务器公告的 `serverInfo.name` 派生命名空间
否决。远端名称不可信、跨部署不唯一、升级时可变;工具标识和权限规则不得静默跟随它。命名空间是本地配置。
### 在工具结果中保留多个 TextBlock
否决。DeepSeek 序列化器中的 `flattenText()` 在将 `ContentBlock[]` 扁平化为协议格式wire format时使用 `join('')`(无分隔符)。多个 text 块会静默丢失块间边界——这是正确性缺陷。所有现有工具返回单个 TextBlockMCP 桥接遵循同一做法。
## 测试
覆盖率按层级命名;每个行为放在能表达它的最低成本层级。
- **单元测试**`tests/mcp-client.spec.ts``tests/apply.spec.ts`mock MCP SDK`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、代切换/回滚、重新同步失败时的保留、结果映射、取消、配置 schema 校验。100% 逐文件覆盖率门禁约束该包。
- **E2E**`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture测试前置数据服务器、`@modelcontextprotocol/server-everything``@modelcontextprotocol/server-filesystem`stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、重复 `serverName` 拒绝、dispose。
- **快照**刻意不做。MCP 工具不引入新的 transcript文本记录呈现面——它们以原始 `ToolDefinition` 注册,通过 ACP 桥接的通用卡片兜底渲染,该兜底已由桥接的单元测试套件固定(`packages/ui/acp/tests/stream-update.spec.ts`)。将 MCP 服务器添加到快照示例的 `cordis.yml` 会改变已固定的 `text-turn` 系统提示词 fixture迫使每条录制的预期输出都需要带密钥重新录制且使每次回放依赖于 spawn 外部 MCP 服务器进程——而新增渲染行为为零。如果后续变更为 MCP 工具引入专属渲染意图,该变更届时自行声明快照覆盖。
## 后果
- 每个 MCP 服务器只需 `cordis.yml` 中的一条配置即完成集成:`serverName: filesystem` 加一条 stdio 命令(或一个 Streamable HTTP URL就能将 `mcp__filesystem__read_file` 放入模型的工具列表,可调用,协议上使用原始的 `read_file`
- 公开名称是会话历史和权限/配置表面的一部分;命名算法是由测试固定的 v1 契约,发布后变更即为破坏性变更。
- `mcp__<serverName>__` 限定符在每个名称上消耗 token。已接受描述和 JSON Schema 在工具定义 token 中占主导,而限定符换来了稳定标识、冲突隔离和 MCP 全局策略匹配模式(`mcp__*``mcp__github__*`)。
- **MCP SDK 稳定性**`@modelcontextprotocol/sdk` 仍在演进中;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用Claude Desktop、Cursor、VS Code因此破坏性变更不太可能悄然发生。
- **工具 schema 质量**MCP 服务器可能暴露描述不佳的工具(模糊的描述、不完整的 JSON Schema。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的。
- **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 有有界的完全停稳过程;卡住的传输层最终在框架层面超时。
- 崩溃恢复是手动的HMR 编辑或重启——v1 已接受;`reconnect` 配置作为未来工作保持开放。

View File

@@ -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-07-session-prefix.md: 322413f541706244a8a9a9113c0b79693fe54ccd
2026-07-07-session-prefix.zh.md: 710cfbd2656d132640d39b1d62374ef2d16be612

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-07-session-prefix.zh.md)
## Problem
A plugin often owns a session-stable opener the model must always see — a skills catalog, an AGENTS.md digest, a workspace baseline. Before this seam the harness offered two homes, and both are wrong for that content. The system prompt is one rendered string: message-shaped content (a user-role `<system-reminder>` envelope, a multi-message primer) does not fit it, and providers weight conversation messages differently from system text. Durable history (`agent.inject()`, a `context/message` at session start) makes the opener permanent: every `deriveMessages()` consumer replays it, the compaction retention walk owns it, forks bake it in stale, and a resume cannot refresh it — a catalog captured at session birth outlives the world it described.

View File

@@ -0,0 +1,43 @@
# Agent Note: 会话前缀——派生历史之前的仅请求消息
Status: implemented
[English](2026-07-07-session-prefix.md) | 中文
## 问题
插件经常拥有一段会话级别稳定的开场内容模型必须始终看到它技能目录、AGENTS.md 摘要、工作区基线。在引入本 seam 之前harness 为这类内容提供了两个归属位置但两者都不合适。系统提示词是一个渲染后的单一字符串消息形态的内容user 角色的 `<system-reminder>` 信封、多消息引导序列)放不进去,而且提供方对会话消息和系统文本的权重处理不同。持久化历史(`agent.inject()`、会话启动时的 `context/message`)使开场内容变为永久:每个 `deriveMessages()` 消费方都会回放它压缩compaction的保留遍历拥有它fork 会将其以陈旧状态固化,恢复也无法刷新它——会话诞生时捕获的目录会比它所描述的世界活得更久。
显而易见的第三种选项——让插件在请求发出途中编辑 `messages`——被[可重建请求 Agent Note](../architecture/2026-07-05-reconstructable-requests.md)禁止:每个由循环构建的请求都是会话日志的纯函数,因此无论哪个通道承载开场内容,都必须精确记录它所发送的内容。缺失的是一个带有持久记录的仅请求消息通道。
## 决策
`agent/session-prefix` 是 agent 事件映射上的一个 waterfall瀑布式事件[`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)):监听器接收一个冻结的空种子并返回扩展(规范的贡献方式是前置插入 `[mine, ...await next()]`在协议格式上产生注册顺序。agent loop智能体循环[`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts))在每个循环实例中触发一次,惰性地在实例首次 `agent/pre-step` 之前执行;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发出的每个请求中置于整个派生历史之前——紧接在提供方的 system 槽位之后([协议格式顺序](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。
三个属性承载了这一设计:
- **仅请求,记录在 header 中。** `deriveMessages()` 从不返回前缀;它唯一的持久记录是实例锚定的 `request/header` 快照上的 `EpochHeader.messagePrefix`——可重建请求 Agent Note 已为请求的非历史部分拥有的通道,因此不引入新的会话事件。配套的 [`dsh-agent-loop/invariant`](../../../../packages/core/agent-loop/src/invariant.ts)对每个循环构建的请求重新计算 `messagePrefix + boundary derivation`;启用该贡献时,未记录的前缀无法到达协议格式。
- **按实例冻结。** 复用是结构性的,而非靠纪律保证:缓存的产物在会话中途不可变,因此提供方的提示词缓存从构造上成立,前缀以每步零边际成本扩展了可缓存区域。进程重启或 `ctx.agents.resume()` 产生新实例:它重新组合,任何漂移都可追溯地落在 `'resume'` header 快照上。这就是本 seam 创建的路由规则:会话冻结的开场内容走前缀;会话中途变化的内容走仅追加历史通道(`agent.inject()` 或工具/prompt-submit 的 `additionalContexts`——[拦截 seam Agent Note](2026-06-30-interception-seams.md)),每条都是一次性支付的持久 `context/message`,之后被前缀缓存覆盖。
- **在持久请求信封中保持精确。** 组合先于实例的首次 `agent/pre-step` 和请求边界。第一个已路由请求会把当前前缀记录在其 header 上,因此步骤后的 token 压力会将精确前缀与实际提示词、工具和已路由模型一起读取;通用的步骤前检查点 seam 不携带压缩专属参数。被取消/dispose 中断的组合会被丢弃,永不缓存:感知中止的监听器的降级回退不会泄漏到后续请求中,下一轮次在活信号下重新组合。
由于组合在边界快照之前运行,组合监听器的会话追加会加入当前请求的派生历史。压缩在结构上不可能触及前缀(或系统提示词):它重写的是表面节点,而 header 状态从不进入表面。
## 测试
[拦截测试](../../../../packages/core/agent-loop/tests/interception.spec.ts)固定了以下行为:没有变更 header 时的组合一次复用、前置插入顺序、空前缀省略、不可变性、组合在步骤前检查点之前完成,以及已路由 header 上的前缀;[取消测试](../../../../packages/core/agent-loop/tests/cancel.spec.ts)固定了丢弃与重新组合。Session、不变式、token-meter 和压缩测试覆盖 header 往返、请求重建与持久前缀感知的压力核算。快照归一化保留前缀计数,[固定 header 场景](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md)拥有内容,默认示例保持无前缀。与提供方无关的 seam 无需专门 e2e带密钥的 [request-cache e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) 覆盖了其缓存经济性。
## 曾考虑的替代方案
- **每请求 `before`/`after` 槽位,每步重新计算**(最初提出的形态:一个每请求触发的 waterfall贡献冻结的 `before` 消息置于历史之前、新鲜的 `after` 消息置于历史之后):否决。每步重新组合 `before` 会引入漂移,必须记录为完整的变更 header`after` 槽位位于不断增长的历史之后,其 token 在每个请求中重复支付,且其后的所有内容不可缓存。对照各替代方案衡量,当前所有更新模式都能通过持久追加更廉价地满足(支付一次,此后缓存读取),而唯一没有归属的内容是会话稳定的开场——它需要的是冻结,而非重新计算。
- **系统提示词分段**`system-prompt/assemble`对此类内容否决。assembly 渲染为单一 `system` 字符串,消息形态的开场放不进去;且系统提示词被设计为每步重新组装(变化时带完整的变更 header而开场内容需要按实例冻结的语义。
- **持久化历史开场**(会话启动时 `inject()`):否决。永久历史正是问题陈述中的失败模式——到处被回放、可被压缩、在恢复后仍保持陈旧状态。
- **按轮次组合而非按实例组合**:否决。轮次边界的重新组合要么与日志静默失同步,要么强制产生变更 header且它每次触发都会破坏提供方缓存。合理的刷新点是实例边界`'resume'` 快照已在那里可追溯地记录漂移。
- **通过 `agent/pre-step` 携带提示词/前缀,用于临时压力估算**:否决,因为它把通用生命周期 seam 耦合到一个消费方,而且仍会遗漏更晚的请求路由和工具;步骤后的回放会从持久的已路由 header 读取请求信封的每个字段。
- **专用会话事件承载前缀**否决。header 事件按设计就是请求的非历史记录;第二个事件会为同一事实提供第二个归属,并多出一个需要保持完整的编解码器。
## 后果
- `agent/pre-step` 保持通用的 `(agent, turn, step, signal)` 检查点。压缩不接收 prefix 参数;`ctx.tokenMeter` 在步骤后从规范的已路由 header 折叠前缀。
- 贡献者的内容在会话中途变化时,直到下一个实例才会被重新读取——这是设计意图。需要会话中途目录更新的部署,应将变更通知路由到仅追加历史通道,支付一条持久 `context/message`
- 被放弃的 `after` 槽位意味着请求尾部附近没有仅请求通道;仓库中没有任何功能需要它,且恢复它会重新引入本设计旨在避免的每步重复支付成本。
- 空组合即为规范缺失:无贡献者的部署不记录额外的 header 字节,其请求就是裸派生。

View File

@@ -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-repeat-tool-guard.md: 67ec29c6c9fa38bf1d5935c469f3f71b1119dc3a
2026-07-08-repeat-tool-guard.zh.md: 01037f29810c781c33beee414e50412a0c9b0f89

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-08-repeat-tool-guard.zh.md)
## Problem
A model stuck in a loop re-issues the same tool call with byte-identical arguments — re-running a failing grep, re-reading an unchanged file, polling a command that already gave its answer — and each round trip burns tokens, wall-clock, and (for paid APIs) money without adding information. The harness has nothing that notices: the loop has no step budget, no plugin tracks call repetition, and the model only escapes when it happens to vary its own behavior. The failure mode is real and cheap to detect — [pi-repeat-tool-guard](https://github.com/Kingwl/pi-repeat-tool-guard) ships exactly this as a pi coding-agent extension: count consecutive identical calls and, past a threshold, append a `<system-reminder>` telling the model to stop repeating itself and change course.

View File

@@ -0,0 +1,76 @@
# Agent Note: 重复工具调用守卫插件
Status: implemented
[English](2026-07-08-repeat-tool-guard.md) | 中文
## 问题
模型陷入循环时,会以字节级相同的参数反复发起同一个工具调用——重新运行一条失败的 grep、重新读取一个未变化的文件、轮询一条已经给出答案的命令——每一轮往返都消耗 token、挂钟时间以及对付费 API 而言金钱却不带来新信息。harness 目前没有任何机制能察觉这一点:循环没有步骤预算,没有插件追踪调用重复,模型只有在碰巧改变自身行为时才能跳出。这种失败模式真实存在且检测成本极低——[pi-repeat-tool-guard](https://github.com/Kingwl/pi-repeat-tool-guard) 正是以 pi coding-agent 扩展的形式提供了这一功能:统计连续相同调用次数,超过阈值后追加一条 `<system-reminder>` 告诉模型停止重复并换个方向。
harness 已经具备 pi 扩展所使用的全部 seam而且更好[拦截 seam Agent Note](2026-06-30-interception-seams.md)赋予 `tools/post-execute` 一种经过认可的方式,将面向模型的上下文附加到已完成的调用上;循环缓冲并注入该上下文,同时保持调用/结果的邻接关系;注入的上下文是一条已记录的 `context/message`——因此原生守卫无需新增会话事件即可满足「模型可见 ⟺ 已记录」规则。缺少的只是插件本身。
## 决策
该守卫是一个循环卫生插件,而非面向模型的工具。它统计对同一工具以相同规范化参数发起的连续调用次数,并在配置的阈值处注入建议性提醒。它从不延迟、阻止或改写调用;模型自行决定是换种方式重试还是结束。
插件为 `@deepseek-ai/dsh-repeat-tool-guard`,位于 `packages/guard/repeat-tool-guard/`,开辟 `guard/` 分组用于循环卫生插件单包package分组有先例[todo-write Agent Note](2026-06-29-todo-write-tool.md)发布了 `todo/tool-todo`)。它注册两个监听器,将状态保存在以存活 `Agent` 对象为键的 `WeakMap` 中——工具注册表是上下文级别的单例,其 waterfall瀑布式事件交错所有 agent智能体的调用subagent 运行在同一个上下文上),因此按 agent 分键是正确性要求,而非锦上添花;弱对象键还使得纯清理用途的 disposal 监听器不再必要。
- **`tools/post-execute`waterfall**——唯一的检测点。监听器同时接收 `(exec, result)`,因此计数和提醒投递无需跨事件的 pending mappi 扩展需要它,仅因为其 `tool_call`/`tool_result` 钩子是分开的事件)。它始终通过 `next()` 委托,当命中阈值时,将提醒前置到下游决策的 `additionalContexts`——这正是[钩子桥接](2026-06-30-hook-bridges.md)已采用的「观察并丰富」姿态,遵守 waterfall 契约。计数放在此处而非 `tools/pre-execute`,因为 post-execute 也会为被拒绝的调用触发(`ToolRegistry.execute` 将 deny 路由到同一条流水线),而模型反复敲击一个被拒绝的调用恰恰是值得打破的循环。
- **`agent/prompt-submit`waterfall**——纯重置钩子:通过 `next()` 委托,清除提交 agent 的链。用户介入改变了上下文;跨越介入的重复不是循环。
### 检测语义
链的键是 `(tool name, canonical arguments)`;与前一个被追踪调用相同的调用递增该 agent 的连续计数器,不同的被追踪调用将其重置为 1。规范化方式为深度键排序加 `JSON.stringify``ToolExecution.arguments` 按构造就是循环中 `JSON.parse` 的输出(或格式错误的参数 JSON 的原始字符串回退,其本身也是可比较的值),因此 pi 原版对 bigint/循环引用/`undefined` 的处理在此没有输入,被有意去除。
两条刻意的规则,均记录在[包 README](../../../../packages/guard/repeat-tool-guard/README.md) 中,因为它们是读者否则只能猜测的行为:
- **未追踪的调用对链透明。** 被 `include`/`exclude` 排除的调用既不递增也不重置计数器,因此 `grep X → todo_write → grep X``todo_write` 被排除时仍计为两次连续的 `grep X`。这正是排除功能有用的原因——穿插在循环中的簿记工具不得为循环洗白——也是 pi 扩展的(未文档化的)语义,有意保留并明确写下。
- **没有 agent 的调用被忽略。** 直接调用 `ctx.tools.execute()` 的调用方(测试、非循环消费方)没有可提醒的模型,也没有可作键的存活 agent 对象。
### 提醒投递
提醒作为独立条目搭载在 `additionalContexts`source 为 `{kind: 'plugin', plugin: 'repeat-tool-guard'}`——依照 `HookContext`,该标签承载语义),绝不替换 `content``tool/result` 事件仍是工具自身的审计输出,循环则在步骤结果之后把缓冲的上下文追加为 `context/message`,会话将其渲染为带标签的合成 user 信封,并由派生历史回放。阈值逐级升级:第一个配置阈值获得简短的「你正在重复自己,请分析先前结果」提示;后续各阈值获得详细形式,包含工具、重复计数和规范参数(在头部截断到 `argumentsPreviewChars`,默认 500——循环中的 `write` 级 payload 不得无界地进入下一次请求链键始终比较完整规范字符串并说明这些调用没有取得进展。pi 原版把温和文本硬编码为字面计数 3本守卫以 `thresholds[0]` 为键,修复了移植中的这一 bug。下游钩子桥贡献仍是独立数组条目因此两个插件都保留各自的 source、信封与元数据。
### 配置
```yaml
- id: repeat-tool-guard
name: '@deepseek-ai/dsh-repeat-tool-guard'
config:
thresholds: [3, 5, 8] # default; consecutive counts that trigger a reminder
include: [] # tool-name patterns to track; empty ⇒ all tools
exclude: [todo_write] # tool-name patterns transparent to the chain
argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder
```
`thresholds` 在加载时校验,遇到空列表、非整数、小于 2 的值或重复项时抛出异常——配置错误快速失败,取代 pi 原版的静默回退到默认值。`include`/`exclude` 条目支持 `*` 通配符。模式是对调用时实际存在的工具的谓词,而非对注册表条目的引用,因此匹配不到当前已注册工具的条目不是错误——与 `toolOrder` 的引用检查不同,`exclude: [mcp_*]` 在未加载 MCP 工具的部署中也必须保持有效。
## 测试
- **单元测试:** 使用脚本化适配器的真实循环覆盖计数与重置规则、未追踪透明性、dispose资源释放清理、按 agent 隔离、规范化参数键序、升级、被拒绝的调用、无 agent 执行、通配符转义、无效配置,以及下游阻止或 replacement 决策,达到逐文件 100% 覆盖率。
- **快照测试:** keyless 的 `repeat-tool-guard` 场景发起五次相同的 `todo_write` 调用,在 ACP 输出和会话日志中固定第三次调用的温和提醒与第五次调用的详细提醒。该插件在实时示例中加载,但在其他场景中保持静默。
- **E2e 测试:** 无。该插件是确定性的且与提供方无关,其 seam 契约由各自的所有者覆盖。
## 曾考虑的替代方案
- **将提醒追加到工具结果中**(以替换 `content` 的方式 `accept`——pi 扩展的机制,它修补结果内容是因为那是其 API 提供的唯一通道):否决。这会让已记录的 `tool/result` 对工具实际返回的内容撒谎,而 `additionalContexts` 是 post-execute 评注的独立认可通道,循环级缓冲保持了调用/结果的邻接关系。
- **在 `tools/pre-execute` 中计数并使用 pending-reminder map**pi 的两阶段形态否决。post-execute 单独就能同时看到 `(exec, result)` 且也为被拒绝的调用触发,因此一个监听器、无跨事件状态即可以更少的机制覆盖严格更多的尝试。
- **在最高阈值升级为 `block`**:在初始范围内否决。阻止调用会惩罚合法的相同重复(轮询长时间运行的终端、重新检查 agent 预期会变化的文件),而建议性提醒让模型保持控制权。待有证据后重新审视;决策形状(`PostToolDecision`)已支持此选项。
- **通过 CC/Codex 桥接的逐部署外部钩子**(一个 `PostToolUse` 脚本):否决作为最终答案。它对单个部署有效,但一个已发布、有单元测试、可通过 `cordis.yml` 配置的插件才是 harness 原生的形式,且没有逐调用的子进程开销。
- **在 `agent-loop` 中设置循环级步骤或重复预算**:否决。「用插件,不改循环」;硬性步骤预算是一种更粗粒度的正交控制,需要自己的提案。
- **模糊/近似相同检测**(路径归一化、相似但不完全相同的参数):否决。规范化后的精确匹配成本低、确定性强、且可向模型解释;相似度阈值引入误报风险,需要证据才能换取复杂度。
- **将包放在 `core/`**否决。core 是产品主干;行为守卫是可选的叶子插件,`todo/` 的先例是每个插件族一个小型专属分组。
## 后果
- 提醒在设计上是建议性的:有意重复相同调用的幂等轮询模式仍会在超过阈值后收到提示,减压阀是配置(`thresholds``exclude`)加上明确允许「在已收集足够证据时结束」的提醒文本。每次触发在下一次请求中增加提醒 token 的开销;阈值限制了触发频率。
- 链状态仅存于内存:从持久化恢复的会话以全新的链开始,因此跨越恢复的循环比实时循环更晚收到提醒——可以接受,守卫是启发式提示而非已记录的不变式,持久化计数器状态带来的收益不值得其复杂度。
- 当多个 post-execute 生产者在同一次调用上附加上下文时,每项贡献保持为独立的 `HookContext`;顺序遵循 waterfall 嵌套关系,每个条目保留自己的溯源信息。
- 实现快照层时暴露了 suite kit 的一项隐藏假设fixture guard 把「撰写的模型场景」等同于「由 override 驱动」。`Scenario` 表现在携带显式的 `overridden` 标志,并且 sidecar 是否存在会以双向方式与其核对(未注册的游离 sidecar 会静默替换派生脚本——suite kit 比本插件出现前更严格。
## 延后事项
- 压缩compaction不重置链压缩后的历史改变了模型所见的内容但重复风险通常在压缩后仍然存在。
- 在高阈值升级为 `block` 未实现;`PostToolDecision` 已支持此选项,待证据到来时启用。
- subagent 的链按 agent 隔离;在出现具体用例之前不提供共享机制。

View File

@@ -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-self-referential-cordis-toolset.md: 80bffa3a2a959939f18fd1d3422607cf61895fc7
2026-07-08-self-referential-cordis-toolset.zh.md: 2ec79037045fdb040cccf31699789abdd3a12db2

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-08-self-referential-cordis-toolset.zh.md)
## Problem
Everything in this harness is a cordis plugin, but the agent running inside that plugin runtime cannot see or touch it: it cannot enumerate the services and events around it, cannot extend itself with a new tool mid-session, and cannot compose capabilities it invents. Handing the model that power is worth exploring — a self-referential agent that inspects and modifies its own runtime — but it raises three correctness problems at once, and the design is about answering them rather than the raw "let the model run code" mechanic.

View File

@@ -0,0 +1,82 @@
# Agent Note: 自引用 cordis 工具集
Status: implemented
[English](2026-07-08-self-referential-cordis-toolset.md) | 中文
## 问题
本 harness 中的一切都是 cordis 插件,但运行在该插件运行时内部的 agent智能体既看不到也碰不到它它无法枚举周围的服务和事件无法在会话中途为自己添加新工具也无法组合自己发明的能力。赋予模型这种能力值得探索——一个能审视并修改自身运行时的自引用 agent——但这同时引发三个正确性问题本设计的核心正是回答这些问题而非单纯的「让模型执行代码」机制。
第一,模型编写的注册必须在注册发生时就完成校验:格式错误的工具 schema 必须在注册时失败,而不是等到后续请求尝试将其组装进提示词时才报错。第二,模型编写的代码需要调用它从未见过源码的服务 API——靠猜测方法签名、更糟糕的是猜测返回值结构会消耗大量盲目试探的步骤。第三模型挂载的一切都必须完全可释放模型可以按需释放普通的插件生命周期在宿主插件重载时也会释放否则长会话会积累遗留的监听器和工具。
## 决策
该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布——一个新的顶层 `packages/cordis/` 分组——并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作模型自身运行其中的活跃 cordis 运行时:审视它、将模型编写的插件挂载进去、再将其释放。
vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:一个挂载可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。
### 三个工具
| 工具 | 契约 |
|---|---|
| `cordis_inspect` | 对活跃运行时的只读报告,每个 `what` 值对应一个 Markdown 段落(省略 `what` 则输出全部段落)。精确的 `name` 搭配 `what: "api"``what: "events"` 可收窄到一个带源码文档的目标。从不产生变更。 |
| `cordis_mount` | 在 `node:vm` 沙箱中执行 `code`(一个异步 JavaScript 函数的函数体);代码必须 `return` 一个 cordis 插件,该插件作为 `cordis-dynamic` 分组 fiber 的子节点挂载,并以一个新 id`dyn-1``dyn-2`……)跟踪。 |
| `cordis_unmount` | 按 id 释放一个动态挂载,并等到释放达到完全停稳后才返回——该插件所做的每一项注册都被撤销,而不仅仅是请求停止。 |
`cordis_inspect` 的段落:`services`(每个已提供的 ctx 服务及其所属 fiber非活跃的所有者会被标记`plugins`(来自 `ctx.registry` 的所有已加载插件的扁平列表及其生命周期状态——展示加载了哪些能力,刻意不展示树形结构)、`tools`(模型可调用的工具)、`dynamic`挂载表id、名称、状态、提供的服务、等待的服务`api`(来自生成目录的活跃服务签名及其引用的类型形状)和 `events`harness 事件及其分发模式和签名)。宽泛的 `api``events` 报告省略完整 JSDoc 以保持紧凑;精确 `name` 会返回一个服务或事件,以及其原始方法/声明 JSDoc。其他段落不能搭配 name未知目标会失败而 API 目标必须处于活跃状态。面向模型的工具描述携带了模型在调用时所需的操作规则;[生成的工具目录](../../../../docs/tool-catalog.md)是其完整呈现。
### 沙箱语义
挂载代码以异步函数体的形式在一个新的 vm realm 中运行。其文档化的接口面将文件、网络、进程和定时器访问引导至 Cordis 服务,使挂载保持可审视和可释放。宿主 realm 的辅助手段仍然使 Node 逃逸成为可能,这与信任姿态一致。`vmTimeoutMs` 仅约束同步执行部分。
沙箱全局变量刻意精简:一个带标签的直写 `console`(在宿主 stdout/stderr 上输出 `[cordis:<id>] …`,这样在挂载调用之后很久才触发的监听器输出仍能落到用户可见的地方)、`harness.defineTool` / `harness.registerTool` 注册对、新 vm 上下文缺少的编码原语(`btoa`/`atob` 作为基于 `Buffer` 的宿主闭包——这是一个经过审批的例外,`Buffer` 本身从不暴露——加上 `TextEncoder`/`TextDecoder`),以及对被扣留的 Node API 的可调用陷阱(`require``setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval``fetch`),这些陷阱会抛出一条重定向消息指明 cordis 替代方案。只有函数形态的全局变量才设陷阱;`process``Buffer` 保持 `undefined`,这样 `typeof` 特性探测保持惰性而不会引爆一个抛异常的访问器。
挂载代码通过三道控制跨越 vm 边界。双 realm `instanceof` 同时识别宿主和 vm 对象。`harness.defineTool` 在宿主 realm 中重建输出 schema投影器将工具体返回值快照为宿主自有的 JSON并让注册表在观测前强制执行[规范工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md)。挂载的插件接收的是一个白名单上下文门面,而非原始或透传的 `Context`;框架管道和以 context 为值的返回会被拒绝。服务读取需要声明 `inject`,保留 Cordis 的激活与卸载语义。`ctx.tools.get` 仅暴露 schema 视图,因此挂载代码无法绕过 `ToolRegistry.execute` 直接调用定义。
边界将无歧义的 JSON-Schema 形式规范化为 `ParameterSchemaSpec`,同时保留 `integer`、原始对象开放性和 required 数组。直接使用 DSL 的对象节点必须声明 `additionalProperties`无效词汇会报错并给出可接受的替代方案。解析错误、TypeScript 错误、缺少 return、Node API 误用和重复工具名等错误信息包含相关源码行或纠正性契约,不叙述实现内部细节。
### 动态分组与挂载生命周期
所有动态挂载都是工具插件下方 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理重载和卸载。挂载会等待 settlement启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的挂载仍然可见,并列出其缺失的注入。`cordis_unmount` 等待挂载 fiber 的释放完成。
### 通过 provide/inject 实现跨挂载组合
挂载之间通过普通的 cordis 服务语义相互关联,以各自的 id 作为生命周期句柄:挂载 A 调用 `ctx.provide('foo', value)`,挂载 B 声明 `inject: ['foo']` 并在 `foo` 存在的瞬间激活;如果 B 先挂载,它保持 pending 状态并列出缺失的服务;卸载 A 使 B 回到 pending其注册被撤销之后重新 provide 会通过一个新的沙箱门面重新运行 B 的 `apply`;重复 provide 会明确报错并指出拥有该服务的 fiber。一个 realm 注意事项:由挂载 provide 的服务值是 vm realm 对象——从任何地方调用其方法都能工作,但消费方不得假设它具有宿主原型。
### 生成的 API 目录
`cordis_inspect` 从生成的目录提供 API 和事件数据,而非维护一份重复的表格。生成器复用 Cordis 目录的 AST 扫描,输出服务摘要、签名、原始服务方法与事件 JSDoc、事件模式、引用的类型声明以及继承的上下文接口面。有歧义的类型名被省略过大的声明被标记为截断。
新鲜度像所有生成产物一样受门禁约束:`pnpm run verify-cordis-api`(在 `doc-sync` 中)在内存中重新生成并在有任何 diff 时失败,因此 JSDoc 或公开签名变更如果不重新生成模型读取的目录就无法合入。运行时 inspect 工具将目录与活跃运行时取交集而非直接转储:宽泛报告把有目录条目的活跃服务渲染为摘要 + 签名,把没有目录条目的活跃服务(挂载提供的)渲染为名称 + 所属 fiber简要列出有目录条目但无活跃提供方的服务再附上引用的类型形状。精确名称报告渲染一个活跃服务或事件并把原始 JSDoc 紧靠在每个签名之前;让该细节按需出现,避免探索性列表承担其 token 成本。
### 配置、渲染与可观测性
该插件暴露一个配置字段,由 schemastery 校验并记录在[配置目录](../../../../docs/config-catalog.md)中:`vmTimeoutMs`(默认 5000挂载代码同步执行部分的毫秒上限。工具名、`cordis-dynamic` 分组名和 `dyn-` id 前缀是结构性词汇,保持固定。三个工具均按[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)渲染为 `generic` 卡片(`cordis_inspect``read``cordis_mount``execute` 并将代码作为 `rawInput` 携带,`cordis_unmount``delete`),不覆盖 `presentResult`
「模型可见 ⟺ 已记录」成立,且无需新的会话事件类型:挂载或卸载仅通过其自身的 `tool/call` / `tool/result` 对可见(循环会记录它们),而挂载引起的工具集变化由循环在 schema 在步骤间发生变化时发出的完整变更 request header 记录。刻意不设 `cordis/mount` 溯源事件——它只会重复工具调用对已记录的内容。动态挂载是进程生命周期的,不是会话状态:恢复一个持久化的会话会重建对话,但不会重新挂载插件。
## 曾考虑的替代方案
**用结构化的逐能力注册工具替代 `cordis_mount`。** 最具吸引力的替代方案是一个带有显式 `name` / `description` / `parameters` / `code` 字段的 `cordis_register_tool`(以及兄弟工具 `cordis_register_listener``cordis_register_service`……),而非单一的「挂载一个插件」原语。否决原因:它唯一的真正优势——对最常见的单一场景免去插件样板代码——不足以抵偿其代价,而单一的 mount 原语能一次性覆盖所有能力。
| 维度 | 结构化逐能力工具 | 单一 `cordis_mount` |
|---|---|---|
| Schema 正确性 | `parameters` 仍然是模型编写的 JSON需要统一 schema 校验,只是提前了一步 | 同样的校验在沙箱边界运行,同样的指导性错误信息 |
| 代码字段 | `execute` 函数体仍然是 vm 中模型编写的 JSrealm 和服务调用的正确性问题不变 | 一个沙箱、一条规范化路径、一处受保护的注册 |
| 能力覆盖面 | 仅限工具;监听器、服务、`inject` 关系各需另一个结构化工具——接口面无限增长 | 一套词汇cordis 插件)覆盖当前和未来的所有效果 |
| 跨挂载组合 | 在工具注册载荷中无法表达 | 原生 `provide`/`inject`,普通的 cordis 语义 |
| 可审视性 | 注册的东西无法在插件列表中显示为插件 | 模型挂载的正是 `cordis_inspect` 渲染的 |
| 模型易用性 | 对最常见的单一场景有优势(无插件样板) | 通过 mount 描述中的规范示例加边界错误信息教会正确调用来缓解 |
因此正确性投入放在能一次性为所有能力带来回报的地方:通过 `cordis_inspect` 呈现的生成 API 目录,以及沙箱边界校验(其错误信息教会正确的调用方式)。结构化注册工具日后仍可作为语法糖添加,由它合成 mount 代码;本设计不排斥这一可能。
**在工具中手工维护服务/事件参考。** inspect 工具的第一版携带了一份手写的服务方法签名表。它被生成的 `api-catalog.ts` 取代,因为手写表在签名变化的瞬间就会与 JSDoc 脱节且没有门禁约束这种漂移,而生成产物的新鲜度由文档使用的同一套 AST 检查。
**新增 `cordis/mount` 会话事件。** 一个持久的溯源事件记录每次挂载(源码、名称)有明确先例(`hook/invoked``compact/start`。v1 中予以否决:挂载和卸载已经作为 `tool/call` / `tool/result` 对可见,工具集变化已经作为完整的变更 request header 被记录,因此专用事件只会重复记录。如果审计用例需要将挂载溯源从工具调用中分离出来,日后仍可添加。
**加固的/能力受限的沙箱。** 对 Node 内置模块设陷阱并向挂载代码提供白名单门面而非原始上下文,可能暗示意图是为安全而沙箱化。这里明确不是:陷阱和门面收窄的是挂载代码所见的*接口面*——将其引导至 cordis 服务、远离易泄漏的 Node 内置模块和框架内部——目的是正确性和封堵未受保护的上下文逃逸,但门面暴露的能力(`ctx.bash``ctx.fs``ctx.web`)触及真实运行时,因此它不是安全边界。真正的安全边界(独立进程、权限提示)超出了一个开发/显式启用工具集的范围,且会与其核心目的——将活跃运行时交给模型——相冲突。
## 后果
该工具集是刻意的显式启用设计,具有完全特权的 `ctx`,因此部署方采用它的意识程度应与 bash 工具相当。以下几个事实由工具描述直接告知模型:一个 waterfall瀑布式事件监听器`tools/pre-execute`)如果不调用 `next()` 就返回,会否决整条链,因此一个挂载的监听器可以瘫痪 agent 自身的工具分发([waterfall 语义](../../../../docs/cordis-primer.md#cordis-waterfall-semantics));挂载代码在当前轮次的工具调用内运行,因此 await 任何只在该轮次结束后才 resolve 的东西会导致死锁;`vmTimeoutMs` 仅约束同步执行;挂载不会在会话恢复后存活。

View File

@@ -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-session-query-service.md: 722f6bf6163278719c3bbb598ed2a2a9d042fb8e
2026-07-10-session-query-service.zh.md: ae6fcb78e18afe83784560c493ea93b7ee0bd68c

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-10-session-query-service.zh.md)
## Problem
Session history exists in two places: current `SessionStore` objects and an optional persistence backend. Consumers that need exact inspection would otherwise duplicate live-versus-persisted precedence, persistence lifecycle handling, raw-event surface classification, relationship tracing, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source.

View File

@@ -0,0 +1,42 @@
# Agent Note: 精确会话查询服务
Status: implemented
[English](2026-07-10-session-query-service.md) | 中文
## 问题
会话历史存在于两处:当前的 `SessionStore` 对象与可选的持久化后端。需要精确检查的消费方若无统一服务,就不得不各自重复实现活跃/持久化优先级判定、持久化生命周期处理、原始事件的 surface 分类、关系追踪以及防御性克隆。在检查点之间,持久化状态可能落后于活跃日志,因此仅靠持久化并非当前状态的可靠来源。
全文搜索与此相关,但规模大得多。将提供方协调、同步、失效、排序和游标状态放入精确读取服务,会在具体数据库拥有方旁边再创建一个状态机。
## 决策
`@deepseek-ai/dsh-session-query` 拥有面向单一逻辑语料库的唯一抽象 `ctx.sessionQuery` 服务。它具体实现 `listSessions()`、提供方无关的 `filterSessions(filters)``listEvents(sessionId)``filterEvents(sessionId, filters)`、有界的 `readEvent(request)``traceSession(sessionId)``traceEvent(request)`,而具体后端实现其两个全文搜索方法。[统一服务决策](../architecture/2026-07-23-unified-session-query-service.md)拥有这一拓扑,[SQLite 搜索决策](2026-07-10-sqlite-session-query-provider.md)拥有搜索行为,[追踪决策](2026-07-13-session-query-tracing.md)拥有血缘与事件关系语义。
该服务动态观察可选的 `ctx.sessionPersistence` 绑定,但不保留持久化缓存或失效监听器。每次跨语料库列表操作向活跃后端请求权威元数据,然后叠加一份新鲜的活跃 store 列表。id 匹配的条目合并为一条 `SessionRecord`:活跃 header 优先,`live`/`persisted` 各自独立报告来源可用性。不可变 header 不一致时产生 `SESSION_QUERY_SOURCE_CONFLICT`
精确目标读取首先检查活跃 store快照活跃 header 与事件日志。此路径从不查询持久化,因此持久化后端故障不会导致已知的活跃历史不可读。若活跃 store 中无目标,服务列出当前持久化元数据、证明该 id 存在、加载它,并在列表/加载 header 不一致时拒绝。所有返回的 header 与事件都经过一次 structured-clone 边界。
## Surface 语义
`dsh-session` 导出 `foldSurface(events)``SurfaceManager` 使用相同的转换函数维护其增量缓存。fold 返回分离的当前事件 seq 以及每次替换实际移除的 seq。`listEvents()``traceEvent()` 利用该结果为每个原始事件分类,使检查结果不会在位置替换语义上与 model-history 推导产生分歧。
`readEvent()` 返回完整的目标加上按连续 seq 排列的原始相邻事件。`before``after` 默认为零,各自受 `readWindowMax`(默认 50约束。结果携带克隆的 `SessionHeader` 而非来源可用性记录,因为判断活跃目标的 persisted 标志会违反「活跃精确读取不依赖持久化健康状态」这一保证。
## 安全边界
该服务是上下文级别的受信任基础设施,而非授权层。未来面向模型的历史工具或人类 UI 将施加显式的调用方/会话范围。该服务不添加面向模型的工具,也不改变 transcript文本记录或快照的 surface。
## 曾考虑的替代方案
- **将逻辑语料库解析直接放在每个消费方中**:否决。来源优先级、冲突处理、可选服务生命周期、克隆与 surface 分类是共享的正确性规则。
- **仅查询持久化**:否决。检查点可能落后于当前活跃日志。
- **缓存持久化元数据并监听写入/删除**:否决。精确读取可以直接询问权威来源,而缓存失效在规模尚未要求时就引入了生命周期与并发状态。
- **将提供方注册放入精确读取服务**否决。SQLite 包拥有一套对账/事务生命周期;若没有第二个提供方证明其必要性,注册表只会拆分该状态。
## 后果
继承的精确读取实现只有一个来源解析状态变量:当前挂载的持久化服务。它没有提供方队列、指纹、提取器注册表、观察代次或派生索引更新;具体后端单独拥有其全文搜索状态。精确读取、语义扫描和事件追踪在纯活跃部署中仍然可用,在持久化存在时具有确定性。
跨语料库列表、血缘追踪和持久化事件操作在每次调用时执行后端 I/O。这是有意为之正确性来自当前权威状态而面向规模的全文搜索方法使用具体后端的 SQLite 派生索引。

View File

@@ -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-sqlite-session-query-provider.md: be8795b609b52eeb03268c4986b52004eef0dba9
2026-07-10-sqlite-session-query-provider.zh.md: bb3650da907cf86a853f748fa0ee40d5c2168709

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-10-sqlite-session-query-provider.zh.md)
## Problem
The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, filters, pagination, cancellation, and rebuild behavior.
@@ -36,7 +38,7 @@ One serialized operation reads the provider-neutral `SessionPersistence` snapsho
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. 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.
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.

View File

@@ -0,0 +1,59 @@
# Agent Note: SQLite FTS5 会话搜索
Status: implemented
[English](2026-07-10-sqlite-session-query-provider.md) | 中文
## 问题
精确读取的 `ctx.sessionQuery` 服务有意不维护派生索引。大规模持久化的历史记录需要全文搜索,而不是每次查询都扫描全部事件;当前的活跃会话则需要一个比上一次持久性检查点更新的覆盖层。搜索还需要具体的排序、摘要片段、过滤器、分页、取消以及重建行为。
如果把这些关注点拆分到提供方协调器和数据库实现之间就会产生两个耦合的对齐状态机。第一个实现既要暴露精简的提供方无关调用契约也要在同一个生命周期内管理源观察、提取、SQLite 事务、代际与查询执行。
## 决策
`@deepseek-ai/dsh-session-query` 声明一个抽象的 `ctx.sessionQuery` 服务,其精确读取、过滤与追踪均有具体实现,仅有两项全文方法为抽象方法。`searchSessions(request, exec?)` 返回按游标分页的 `SessionSearchHit`,并按每个会话中匹配度最强的事件分组;`searchEvents(request, exec?)` 返回一个逻辑会话内的 `SessionEventSearchHit`。两种请求都必须提供 `query`,可以接受 `limit` 和由服务拥有的品牌化 `SessionSearchCursor`,并支持可选的中止信号。会话搜索接受 `sessionFilters` 与事件元数据过滤器,事件搜索接受事件元数据过滤器。结果会公开有界的纯文本摘要片段,但不公开提供方标识符或数值相关性分数。单一键拓扑由[统一服务决策](../architecture/2026-07-23-unified-session-query-service.md)定义。
`@deepseek-ai/dsh-session-query-sqlite` 扩展接口服务,并且是 `ctx.sessionQuery` 唯一的具体所有者。它依赖实时的 `ctx.sessions`,动态观察可选的 `ctx.sessionPersistence`,并拥有一个专用的派生 SQLite 数据库。系统没有搜索提供方注册表、协调器、持久化事件或 agent loop智能体循环集成。
接口包还拥有共享的第一方语义提取与提供方无关的过滤。`SessionResultFilter` 涵盖 id、可空的 cwd、创建时间范围、可空的父会话与可用性`ctx.sessionQuery.filterSessions()` 无需 FTS 提供方即可应用这些过滤器。`SessionEventResultFilter` 涵盖 seq/时间范围、事件类型、surface 与字面语义文本。过滤器数组内各项按逻辑与AND组合列表值按逻辑或OR组合。文本子句会将调用方输入转义为不区分大小写的 Unicode 正则表达式,其中每段连续空白都匹配一个或多个空白字符;该子句通过 `ctx.sessionQuery.filterEvents()` 提供,不会委托给 FTS 提供方。
## 搜索语义
每个语义事件对应一份 FTS 文档其中携带会话元数据、事件元数据、surface 分类与提取文本。除非 surface 过滤器缩小范围,否则所有 `current``shadowed``log-only` 文档都会参与搜索。元数据过滤器在排序前编译为参数化 SQL。会话结果按会话划分匹配文档并保留匹配度最强的文档。
排序在持久化 FTS 表与 TEMP FTS 表之间具有确定性和可比性:先按实际 FTS5 高亮匹配区段数量降序,再按已索引文档的码点长度升序、事件时间降序、跨会话范围内的会话 id 升序,最后按 seq 降序排列。摘要片段使用这些实际高亮位置,移除保留标记、规范化空白,并按 Unicode 码点限制长度。不透明游标会绑定到服务实例、范围、规范化后的标准请求、偏移量与相关代际。语料库发生任何变更都会使跨会话游标失效;会话内游标仅在其目标源或代际发生变化时失效,因此不相关的会话不会使其失效。重新打开服务会创建新的服务实例,并使旧游标失效。
查询会先去除首尾空白并规范化内部空白,再作为一个字面 FTS5 短语整体加引号。嵌入的引号在绑定前写成两个,因此 `OR``NEAR`、引号、括号与 `*` 等 MATCH 运算符会作为数据,而不是可执行的查询语法。系统会在 SQLite 执行前拒绝 NUL。文档中的保留高亮非字符与 NUL 会在索引前规范化,因此插入的呈现标记不会与源文本冲突。短语匹配遵循分词器 token而不是任意子串。
## 分词器选择
持久化 FTS5 表与实时 FTS5 表都使用 `unicode61`。实现实验表明,该分词器支持由两个字符组成的 token `AI`,生成的索引体积约为 trigram 方案的 1/2.1。系统接受的限制是 token/短语召回:`AI` 不会匹配较长的 token `BRAID`,任意子串搜索改用提供方无关的文本扫描。
## 提取与对齐
共享提取器会提取消息文本、推理reasoning、嵌套的工具调用/结果内容、工具名称与参数、被阻止提示词的原因、待办事项状态与内容,以及错误或结束状态详情。结构性边界、流式分片、请求头、成功完成标记,以及通过声明合并扩展的未知事件/内容变体都不会产生文档。surface 分类复用 `foldSurface()`,使搜索与模型历史派生保持一致。
一个串行化操作会读取提供方无关的 `SessionPersistence` 快照清单,将每个包含源身份的不透明修订号与同已索引会话一并存储的修订号比较,只加载新增或变更的日志,在一个事务中对齐各行,然后执行查询。它绝不会调用后端会修改状态的 `load()` 来处理当前由 `ctx.sessions` 拥有的 idTEMP 覆盖层会记录持久化可用性,实时所有者分离后,持久化基础层随之刷新。修订号同时标识其底层持久化存储与后端本地日志修订版本,因此针对同一存储重新打开服务可以复用已索引行,而切换到独立存储时不会因会话 id 与本地计数器相同而发生冲突。如果加载期间清单发生变化,系统会重复观察;因此,会修改状态的加载修复所产生的新修订号会在提交前纳入结果。重复查询与针对未变更存储的重新打开都不会加载完整的持久化日志。新增、变更与删除的会话会在下一次稳定搜索中更新。源或提取失败不能将某一行标记为当前状态,事务失败则会回滚,使后续搜索能够重试。
持久化文档在重启后仍然存在。实时会话使用连接本地的 TEMP 表,遮蔽相同 id 的持久化基础行,并在实时所有者分离时重新显露该基础行。关闭数据库会删除实时行。卸载持久化服务会隐藏持久化行,但不会把缺失视为权威删除;重新挂载后,系统会再次观察并对齐后端。实时会话头与持久化会话头的不可变字段发生冲突时,系统会失败,而不会合并两个来源。
派生 schema 拥有独立的 application id 与单调递增的 schema 版本。持久化与 TEMP 会话元数据均遵循 `SessionHeader.createdAt` 的整数契约,将其存入严格的 `INTEGER` 列。系统识别到不兼容版本时,只会重置该派生数据库。如果数据库具有不属于本应用的 application id 或无法识别的用户表,系统会在修改日志模式前拒绝该数据库,防止意外配置的规范会话数据库遭到修改。在 POSIX 文件系统上,缺失的目录与数据库文件会以仅所有者可访问的权限创建,使新的 SQLite 伴随文件沿用该模式;现有权限模式保持不变。一个进程中的一个服务独占一条派生索引路径;代际与实时 TEMP 遮蔽状态都归连接所有,因此不支持跨进程写入方。
取消会拒绝排队中的操作并终止调用方对异步源观察的等待已经中止的观察结果不会提交。Node 的同步 `DatabaseSync` MATCH 调用一旦开始在 JavaScript 线程上执行就无法中断,因此服务会在串行化边界检查信号,但不承诺在语句执行期间抢占。
## 曾考虑的替代方案
- **将 FTS 表添加到规范持久化数据库**:不予采纳,因为可重建索引不应与权威日志共享 schema、重置或故障边界。
- **添加第一阶段的提供方注册表与协调器**:不予采纳,因为单一实现无法证明注册语义,并且会将一个对齐生命周期拆给两个所有者。
- **立即持久化实时覆盖层**:不予采纳,因为在现有检查点提交前,实时事件并非规范数据。
- **使用 FTS5 trigram 分词器**:不予采纳,因为它会遗漏短于三个字符的有用查询,并且测得的索引体积约为 `unicode61` 的 2.1 倍;扫描路径仍可提供字面子串过滤。
- **在每个表中独立使用 FTS5 BM25**:不予采纳,因为填充内容不同的持久化语料库与 TEMP 语料库所产生的分数不可比较;实际匹配区段与文档长度采用同一套尺度。
## 后果
搜索只公开精简的提供方无关 API而唯一后端负责派生索引的全部状态转换。独立数据库增加了配置与查询前的轻量快照读取但索引损坏、重置与分词器变更都不会危及规范日志。持久化修订号使未变更会话无需读取或重写完整日志TEMP 实时覆盖层保留当前会话事实,同时不会让尚未经过检查点的事件具有持久性。
选定的分词器以较小的索引体积支持短 token但不承诺子串召回。字面短语使查询语法安全且可预测代价是不支持布尔表达式或完整 MATCH 表达式。取消在操作排队或等待数据源期间有效,但同步 SQLite 执行仍是不可抢占区段。
单元测试将以下行为固化为契约:提取、过滤器、两种搜索范围、所有默认 surface、先过滤元数据再排序、摘要片段、字面量转义、确定性平局处理、完整分页、按范围的游标失效、动态挂载/卸载持久化服务、重启对齐、实时遮蔽、显露与重新打开、schema 安全、回滚重试,以及排队中或进行中的数据源等待取消。一个无需密钥的真实 Loader 路径测试会将该包与真实的 SQLite 持久化后端组合使用。

View File

@@ -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-12-subagent-persona-tool-filter-and-depth.md: c690f4701a54272205eedf719fbfe0863bac566c
2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: 6e9e9ad44fff4dee6ddb286227485420d100f4ee

View File

@@ -2,6 +2,8 @@
Status: implemented
English | [中文](2026-07-12-subagent-persona-tool-filter-and-depth.zh.md)
## Problem
A reusable subagent provider answers how to run a child, but different delegation tools need different child behavior. One deployment may want a reviewer persona, a research-only tool set, or a hard recursion bound without creating a new provider for every combination.

View File

@@ -0,0 +1,96 @@
# Agent Note: 配置 subagent 的人设、工具可见性与深度
Status: implemented
[English](2026-07-12-subagent-persona-tool-filter-and-depth.md) | 中文
## 问题
一个可复用的 subagent 提供方解决的是「如何运行子 agent智能体」的问题但不同的委派工具需要不同的子 agent 行为。某个部署可能需要评审者人设、仅限研究的工具集,或硬性递归上限,而不必为每种组合创建新的提供方。
这些控制影响子 agent 的第一次模型请求,因此不能在子 agent 可见之后再安装。它们还需要提供方的诚实支持ACPAgent Client Protocol后端不能默默接受一个仅限进程内的工具过滤器而过滤器在所有插件运行于同一可信进程的情况下也不应被描述为安全边界。
## 决策
subagent 启动有三个独立的组合控制:`persona``toolFilter``maxDepth`。提供方声明对每个控制的支持情况,服务在启动运行之前拒绝不受支持的请求,进程内提供方在子 agent 尚未发布时安装所请求的组合。
这些控制回答不同的问题:
| 控制 | 问题 | 结果 |
|---|---|---|
| `persona` | 什么角色指令替换该子 agent 的部署人设? | 一个子 agent 局部的提示词段落遮蔽 `deployment:persona` |
| `toolFilter` | 部署全局工具中哪些进入该子 agent 的可见工具视图? | 一个有作用域的限制在添加子 agent 局部工具之前过滤全局工具 |
| `maxDepth` | 这棵委派树最深可以长到多少层? | 子 agent 深度超过绝对上限时,启动请求被拒绝 |
`dsh-tool-subagent` 将这些控制作为插件配置暴露,并复制到它创建的每个请求中。直接调用 `SubagentService` 的调用方可以按请求选择这些控制。提供方的能力描述符仍然是后端能否兑现各字段的真源。
### 人设是有作用域的遮蔽
人设控制改变一个子 agent 的行为,而不改变部署级的提示词组装。在未发布的设置阶段,进程内提供方在子 agent 作用域中注册一个名为 `deployment:persona` 的段落;普通的最具体者优先解析规则仅在该子 agent 的组装中替换全局段落。
其值与部署人设具有相同的严格模板语义。省略时通过全局层继承部署段落;显式空字符串则以空段落遮蔽全局人设。父级和兄弟级的人设永远不会进入子 agent 的扁平作用域。
这使用的是常规的系统提示词注册机制,而非第二条人设通道。因此第一次提示词看到的命名贡献与后续提示词和提示词检查工具看到的一致。
### 工具过滤是一条作用于全局视图的活规则
工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRegistry.restrict()`注册表的单一解析器对协议格式wire format的工具 schema、查找、执行和 Code Mode SDK 生成施加相同的结果。独立注册的系统提示词段落不在 `ToolRegistry` 内,因此过滤一个工具不会移除该插件的独立指导文本。
解析遵循以下规则:
1. 每条限制对活跃的部署全局工具注册表先应用 `allow` 再应用 `deny`
2. 多条限制取交集,因此每条已安装的限制都必须放行一个全局工具。
3. 子 agent 作用域的工具在全局过滤之后添加,可以遮蔽一个已放行的全局工具。
4. 保留的 `run_code` 呈现和其他作用域局部的协议贡献不受全局过滤器影响。
当过滤器既未提供 `allow` 也未提供 `deny`,或命名了当前全局可限制集合之外的内容(包括仅作用域局部或保留名称)时,配置会显式失败。`allow: []` 合法,且有意隐藏所有全局工具。这些检查能捕获拼写错误,并防止配置在无法影响所命名条目时看起来仍然有效。
全局注册表保持活跃。仅 deny 的过滤器会放行后来注册的全局名称(除非显式 deny 该名称allow 列表会排除后来注册的全局名称(除非显式 allow 该名称)。移除一个全局工具会将其从所有已解析视图中移除。这些语义在保持热注册的同时,使 allow 与 deny 的区别显式化。
### 深度是绝对的树上限
深度限制独立于工具可见性来约束递归委派。顶层 agent 深度为零;进程内子 agent 的深度为其父级已验证深度加一。`maxDepth` 是一个绝对的非负安全整数,当推导出的子 agent 深度大于上限时,启动在子 agent 所有权开始之前即被拒绝。
有效父级深度取持久 `SessionHeader.delegationDepth` 与运行时 `AgentOptions.subagentDepth` 中的较大值。进程内子 agent 把推导出的深度记录在会话 header 中,恢复时会重新载入该 header因此重启无法降低递归计数。
每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 Loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代:[SDK 辅助函数生成的 subagent 条目](../../../../packages/sdk/helper/src/features/builtin/index.ts)和 [JSON-RPC 示例](../../../../examples/jsonrpc-agent/cordis.yml)采用这项通用策略,而已交付的交互式 ACP、headless 和 REPL 示例固定为一。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。
部署可以组合深度与过滤,但数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。可见性策略固定的部署可以另外在子 agent 中 deny 委派工具。两种选择都不改变提供方的对话历史行为。
### 能力门控保持提供方诚实
能力将请求的功能与提供方实现分离。`SubagentCapabilities` 声明 `persona``toolFilter``depthLimit``SubagentService.start()` 在调用提供方之前,对照这些标志检查请求中每个存在的字段。
这使 spawn 和 fork 提供方可以共享进程内实现,而外部提供方只声明自己能强制执行的部分。请求永远不会静默降级:选择不受支持的控制会产生 `UNSUPPORTED_CAPABILITY`,不会有运行或生命周期事件存在。
### 未发布设置使第一次请求正确
所有子 agent 局部的组合在子 agent 变得可观察之前完成。进程内提供方向 agent 创建提供一个设置回调;该回调在子 agent 作用域中安装人设、工具限制和结构化输出贡献。只有设置成功后,创建才发布会话和 agent 并允许驱动器启动。
设置失败会回滚私有子 agent。没有观察者能获取到一个「第一次提示词使用了部署人设或未过滤工具集、后续提示词才使用所请求配置」的子 agent。
## 可见性不是授权
这些控制组合的是同一可信进程内的行为,而非授权行为。`toolFilter` 改变工具注册表解析出的子 agent 视图,但它不创建父到子的授权格,不要求子 agent 仅持有父级子集授权,不沙箱化插件,也不阻止持有另一个 Cordis 上下文的代码直接调用服务。
具体而言,子 agent 局部工具在全局过滤之后添加,可能不在父级视图中。仅 deny 的子 agent 也能看到 deny 列表未命名的后来全局工具。这些是有意的活组合语义,而非不可升权保证。
安全设计需要独立的授权表示、传播规则和执行时强制点。创建时的授权快照、父级子集授权、显式的未来授权 API以及通用的能力/输出/终止标签均不在本功能范围内。
## 曾考虑的替代方案
**为每种人设或工具集创建一个提供方。** 这会使共享相同传输和生命周期实现的提供方成倍增加,使动态部署配置变得笨拙,且仍需要递归机制。提供方的职责是执行传输;请求承载每个子 agent 的组合。
**复制父级的完整工具视图。** 注册作用域设计上是扁平的,生命周期所有权不意味着可见性继承。复制已解析视图还会冻结动态全局注册,并在未完整定义任一契约的情况下混淆组合与授权。
**在子 agent 创建时快照允许的全局工具。** 冻结的 allow 集合使未来注册统一不可用,但它改变了热注册语义并开启了授权设计。已实现的过滤器保持为活跃的注册表谓词,并直接记录 allow 与 deny 的行为。
**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 Code Mode 或伪造调用执行一个提示词声称不存在的工具。改为由一个解析器同时管控呈现和执行。
**把深度上限编码为自动工具过滤器。** 创建时过滤器会快照一个可能依赖运行时状态的决策,只影响一个已配置工具名,且不保护直接服务调用方或替代委派工具。提供方改为在每次启动时强制绝对上限。
## 后果
贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始之前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。
代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。当前深度策略禁止再创建子 agent 后,模型仍可能调用可见的委派工具并收到错误。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的不可升权问题。

View File

@@ -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-model-facing-goal-tools.md: 7cc3907d708115207e166455ea988120a03d768b
2026-07-19-model-facing-goal-tools.zh.md: 1a381160354d6a2a24f957f41bc9e375c1ab01ca
2026-07-19-model-facing-goal-tools.md: 286329390a058c0302520fd2203e5becb8c81395
2026-07-19-model-facing-goal-tools.zh.md: b0b4fc99ada3597fbab58081f52309e21dd43bac

View File

@@ -16,11 +16,11 @@ The surface also needs to preserve the separation between durable state and live
### Tools and model contract
`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code.
`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. The executor treats exact empty-string optional fields and a zero `max_goal_rounds` as strict-schema fillers: they count as omitted, an edit still requires at least one meaningful replacement, and all non-filler values retain the action restrictions.
The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition.
All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; activation is reported only as live observation and is never written into replay state.
All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. ACP presentation is a pure function of arguments and uses generic read or mutation cards; mutation cards select meaningful action values before the goal id, so accepted fillers cannot blank their input. Activation is reported only as live observation and is never written into replay state.
An autonomous goal round that successfully reports completion or blocking contributes the existing terminal `agent/turn-stop` decision for that physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not contribute a terminal stop: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary continuation folding.
@@ -38,7 +38,7 @@ Complete and blocked accept either direct-human authority or the exact current g
## Testing
Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/edit/pause/resume behavior, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate.
Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, filler-safe generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/partial-edit/pause/resume behavior including strict-schema fillers, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives a strict-filler `update_goal` probe plus `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate.
## Alternatives considered
@@ -48,6 +48,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr
- **Authorize from persisted root or fork metadata** — rejected because a fork that becomes an independently resumed top-level session should accept new human authority, while a currently owned child should not.
- **Let autonomous rounds edit or resume the goal** — rejected because continuation authority is narrower than authority to redefine or restart the human objective.
- **Treat the blocked threshold as an evaluator** — rejected because event counts cannot prove that an obstacle is semantically unchanged or truly terminal.
- **Reject every present action-specific field** — rejected because strict-schema providers can serialize zero-value placeholders for every optional field; only meaningful values can express a conflicting action.
## Consequences
@@ -56,6 +57,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr
- Human requests can create and rearm goals through ordinary natural language, while restored sessions remain inert until such input arrives.
- Goal rounds can finish or report a repeated blocker but cannot broaden their own mandate.
- Deployment policy selects the blocking lower bound; the same resolved value controls enforcement and prompt guidance.
- Strict-schema provider fillers interoperate without allowing meaningful cross-action updates.
## Known limitations and deferred work

View File

@@ -16,11 +16,11 @@ Status: implemented
### 工具与模型契约
`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit``pause``resume``complete``blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。
`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大目标回合数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit``pause``resume``complete``blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。
提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或派生后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。
三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;激活态仅作为实时观察返回,绝不会写入回放状态。
三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。ACP 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。
自主目标回合成功报告完成或阻塞后,插件会为该物理轮次贡献现有的终止型 `agent/turn-stop` 决策,避免再发起一次不必要的模型请求。直接人类发起的变更不会贡献终止决策:智能体可以确认该变更,并且并发的人类 steering转向仍可参与普通的继续执行折叠。
@@ -38,7 +38,7 @@ Status: implemented
## 测试
单元测试固定注册与释放、独占调度、生成的提示词策略、通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/编辑/暂停/恢复行为、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动 `create_goal``get_goal`,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。
单元测试固定注册与释放、独占调度、生成的提示词策略、可安全处理占位值的通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后派生根的权限、steering、发起者不匹配、读取/创建/部分字段编辑/暂停/恢复行为(包括严格 schema 占位值)、条件式阻塞说明、会话启动边沿后的重新激活、权限先于条件参数失败、准确目标回合的完成、仅自主回合触发终止、可配置阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动一次携带严格 schema 占位值的 `update_goal` 探测,以及对 `create_goal``get_goal` 的调用,固定 stream-json 转录,并检查外部持久化的目标变更。这里有意不把 echo-agent 测试夹具当作应用 UX 的替代品。
## 考虑过的替代方案
@@ -48,6 +48,7 @@ Status: implemented
- **根据持久的根或派生元数据授权**——不予采纳,因为成为独立恢复顶层会话的派生应接受新的人类权限,而当前仍受所有权约束的子智能体则不应接受。
- **允许自主回合编辑或恢复目标**——不予采纳,因为继续执行权限比重新定义或重启人类目标的权限更窄。
- **把阻塞阈值当作评估器**——不予采纳,因为事件计数无法证明障碍在语义上未改变或确实不可继续。
- **拒绝所有已提供的特定操作字段**——不予采纳,因为采用严格 schema 的提供方可能为每个可选字段序列化零值占位符;只有有实际意义的字段值才能表示与指定操作相冲突的另一项操作。
## 后果
@@ -56,6 +57,7 @@ Status: implemented
- 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止。
- 目标回合可以完成或报告重复阻塞,但不能自行扩大任务权限。
- 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。
- 系统可兼容采用严格 schema 的提供方所填入的占位值,同时不会放行有实际意义的跨操作更新。
## 已知限制与延期工作